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    /// Check if keys are sorted alphabetically (case-insensitive)
143    fn are_keys_sorted(keys: &[String]) -> bool {
144        if keys.len() <= 1 {
145            return true;
146        }
147
148        for i in 1..keys.len() {
149            if keys[i].to_lowercase() < keys[i - 1].to_lowercase() {
150                return false;
151            }
152        }
153
154        true
155    }
156
157    /// Check if indexed keys are sorted alphabetically (case-insensitive)
158    fn are_indexed_keys_sorted(keys: &[(usize, String)]) -> bool {
159        if keys.len() <= 1 {
160            return true;
161        }
162
163        for i in 1..keys.len() {
164            if keys[i].1.to_lowercase() < keys[i - 1].1.to_lowercase() {
165                return false;
166            }
167        }
168
169        true
170    }
171
172    /// Get the expected order of keys
173    fn get_sorted_keys(keys: &[String]) -> Vec<String> {
174        let mut sorted = keys.to_vec();
175        sorted.sort_by_key(|a| a.to_lowercase());
176        sorted
177    }
178
179    /// Get the expected order of indexed keys
180    fn get_sorted_indexed_keys(keys: &[(usize, String)]) -> Vec<String> {
181        let mut sorted: Vec<String> = keys.iter().map(|(_, k)| k.clone()).collect();
182        sorted.sort_by_key(|a| a.to_lowercase());
183        sorted
184    }
185}
186
187impl Rule for MD072FrontmatterKeySort {
188    fn name(&self) -> &'static str {
189        "MD072"
190    }
191
192    fn description(&self) -> &'static str {
193        "Frontmatter keys should be sorted alphabetically"
194    }
195
196    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
197        if !self.config.enabled {
198            return Ok(Vec::new());
199        }
200
201        let content = ctx.content;
202        let mut warnings = Vec::new();
203
204        if content.is_empty() {
205            return Ok(warnings);
206        }
207
208        let fm_type = FrontMatterUtils::detect_front_matter_type(content);
209
210        match fm_type {
211            FrontMatterType::Yaml => {
212                let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
213                if frontmatter_lines.is_empty() {
214                    return Ok(warnings);
215                }
216
217                let keys = Self::extract_yaml_keys(&frontmatter_lines);
218                if Self::are_indexed_keys_sorted(&keys) {
219                    return Ok(warnings);
220                }
221
222                let has_comments = Self::has_comments(&frontmatter_lines);
223                let sorted_keys = Self::get_sorted_indexed_keys(&keys);
224                let current_order = keys.iter().map(|(_, k)| k.as_str()).collect::<Vec<_>>().join(", ");
225                let expected_order = sorted_keys.join(", ");
226
227                let fix = if has_comments {
228                    None
229                } else {
230                    Some(Fix {
231                        range: 0..0,
232                        replacement: String::new(),
233                    })
234                };
235
236                let message = if has_comments {
237                    format!(
238                        "YAML frontmatter keys are not sorted alphabetically. Expected order: [{expected_order}]. Current order: [{current_order}]. Auto-fix unavailable: frontmatter contains comments."
239                    )
240                } else {
241                    format!(
242                        "YAML frontmatter keys are not sorted alphabetically. Expected order: [{expected_order}]. Current order: [{current_order}]"
243                    )
244                };
245
246                warnings.push(LintWarning {
247                    rule_name: Some(self.name().to_string()),
248                    message,
249                    line: 2, // First line after opening ---
250                    column: 1,
251                    end_line: 2,
252                    end_column: 1,
253                    severity: Severity::Warning,
254                    fix,
255                });
256            }
257            FrontMatterType::Toml => {
258                let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
259                if frontmatter_lines.is_empty() {
260                    return Ok(warnings);
261                }
262
263                let keys = Self::extract_toml_keys(&frontmatter_lines);
264                if Self::are_indexed_keys_sorted(&keys) {
265                    return Ok(warnings);
266                }
267
268                let has_comments = Self::has_comments(&frontmatter_lines);
269                let sorted_keys = Self::get_sorted_indexed_keys(&keys);
270                let current_order = keys.iter().map(|(_, k)| k.as_str()).collect::<Vec<_>>().join(", ");
271                let expected_order = sorted_keys.join(", ");
272
273                let fix = if has_comments {
274                    None
275                } else {
276                    Some(Fix {
277                        range: 0..0,
278                        replacement: String::new(),
279                    })
280                };
281
282                let message = if has_comments {
283                    format!(
284                        "TOML frontmatter keys are not sorted alphabetically. Expected order: [{expected_order}]. Current order: [{current_order}]. Auto-fix unavailable: frontmatter contains comments."
285                    )
286                } else {
287                    format!(
288                        "TOML frontmatter keys are not sorted alphabetically. Expected order: [{expected_order}]. Current order: [{current_order}]"
289                    )
290                };
291
292                warnings.push(LintWarning {
293                    rule_name: Some(self.name().to_string()),
294                    message,
295                    line: 2, // First line after opening +++
296                    column: 1,
297                    end_line: 2,
298                    end_column: 1,
299                    severity: Severity::Warning,
300                    fix,
301                });
302            }
303            FrontMatterType::Json => {
304                let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
305                if frontmatter_lines.is_empty() {
306                    return Ok(warnings);
307                }
308
309                let keys = Self::extract_json_keys(&frontmatter_lines);
310
311                if keys.is_empty() || Self::are_keys_sorted(&keys) {
312                    return Ok(warnings);
313                }
314
315                let sorted_keys = Self::get_sorted_keys(&keys);
316                let current_order = keys.join(", ");
317                let expected_order = sorted_keys.join(", ");
318
319                // JSON has no comments, always fixable
320                let fix = Some(Fix {
321                    range: 0..0,
322                    replacement: String::new(),
323                });
324
325                let message = format!(
326                    "JSON frontmatter keys are not sorted alphabetically. Expected order: [{expected_order}]. Current order: [{current_order}]"
327                );
328
329                warnings.push(LintWarning {
330                    rule_name: Some(self.name().to_string()),
331                    message,
332                    line: 2, // First line after opening {
333                    column: 1,
334                    end_line: 2,
335                    end_column: 1,
336                    severity: Severity::Warning,
337                    fix,
338                });
339            }
340            _ => {
341                // No frontmatter or malformed - skip
342            }
343        }
344
345        Ok(warnings)
346    }
347
348    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
349        if !self.config.enabled {
350            return Ok(ctx.content.to_string());
351        }
352
353        let content = ctx.content;
354
355        let fm_type = FrontMatterUtils::detect_front_matter_type(content);
356
357        match fm_type {
358            FrontMatterType::Yaml => self.fix_yaml(content),
359            FrontMatterType::Toml => self.fix_toml(content),
360            FrontMatterType::Json => self.fix_json(content),
361            _ => Ok(content.to_string()),
362        }
363    }
364
365    fn category(&self) -> RuleCategory {
366        RuleCategory::FrontMatter
367    }
368
369    fn as_any(&self) -> &dyn std::any::Any {
370        self
371    }
372
373    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
374    where
375        Self: Sized,
376    {
377        let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
378        Box::new(Self::from_config_struct(rule_config))
379    }
380}
381
382impl MD072FrontmatterKeySort {
383    fn fix_yaml(&self, content: &str) -> Result<String, LintError> {
384        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
385        if frontmatter_lines.is_empty() {
386            return Ok(content.to_string());
387        }
388
389        // Cannot fix if comments present
390        if Self::has_comments(&frontmatter_lines) {
391            return Ok(content.to_string());
392        }
393
394        let keys = Self::extract_yaml_keys(&frontmatter_lines);
395        if Self::are_indexed_keys_sorted(&keys) {
396            return Ok(content.to_string());
397        }
398
399        // Parse and re-serialize with sorted keys
400        let fm_content = frontmatter_lines.join("\n");
401
402        match serde_yml::from_str::<serde_yml::Value>(&fm_content) {
403            Ok(value) => {
404                if let serde_yml::Value::Mapping(map) = value {
405                    let mut sorted_map = serde_yml::Mapping::new();
406                    let mut keys: Vec<_> = map.keys().cloned().collect();
407                    keys.sort_by_key(|a| a.as_str().unwrap_or("").to_lowercase());
408
409                    for key in keys {
410                        if let Some(value) = map.get(&key) {
411                            sorted_map.insert(key, value.clone());
412                        }
413                    }
414
415                    match serde_yml::to_string(&sorted_map) {
416                        Ok(sorted_yaml) => {
417                            let lines: Vec<&str> = content.lines().collect();
418                            let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
419
420                            let mut result = String::new();
421                            result.push_str("---\n");
422                            result.push_str(sorted_yaml.trim_end());
423                            result.push_str("\n---");
424
425                            if fm_end < lines.len() {
426                                result.push('\n');
427                                result.push_str(&lines[fm_end..].join("\n"));
428                            }
429
430                            Ok(result)
431                        }
432                        Err(_) => Ok(content.to_string()),
433                    }
434                } else {
435                    Ok(content.to_string())
436                }
437            }
438            Err(_) => Ok(content.to_string()),
439        }
440    }
441
442    fn fix_toml(&self, content: &str) -> Result<String, LintError> {
443        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
444        if frontmatter_lines.is_empty() {
445            return Ok(content.to_string());
446        }
447
448        // Cannot fix if comments present
449        if Self::has_comments(&frontmatter_lines) {
450            return Ok(content.to_string());
451        }
452
453        let keys = Self::extract_toml_keys(&frontmatter_lines);
454        if Self::are_indexed_keys_sorted(&keys) {
455            return Ok(content.to_string());
456        }
457
458        // Parse and re-serialize with sorted keys
459        let fm_content = frontmatter_lines.join("\n");
460
461        match toml::from_str::<toml::Value>(&fm_content) {
462            Ok(value) => {
463                if let toml::Value::Table(table) = value {
464                    // toml crate's Table is already a BTreeMap which is sorted
465                    // But we need case-insensitive sorting
466                    let mut sorted_table = toml::map::Map::new();
467                    let mut keys: Vec<_> = table.keys().cloned().collect();
468                    keys.sort_by_key(|a| a.to_lowercase());
469
470                    for key in keys {
471                        if let Some(value) = table.get(&key) {
472                            sorted_table.insert(key, value.clone());
473                        }
474                    }
475
476                    match toml::to_string_pretty(&toml::Value::Table(sorted_table)) {
477                        Ok(sorted_toml) => {
478                            let lines: Vec<&str> = content.lines().collect();
479                            let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
480
481                            let mut result = String::new();
482                            result.push_str("+++\n");
483                            result.push_str(sorted_toml.trim_end());
484                            result.push_str("\n+++");
485
486                            if fm_end < lines.len() {
487                                result.push('\n');
488                                result.push_str(&lines[fm_end..].join("\n"));
489                            }
490
491                            Ok(result)
492                        }
493                        Err(_) => Ok(content.to_string()),
494                    }
495                } else {
496                    Ok(content.to_string())
497                }
498            }
499            Err(_) => Ok(content.to_string()),
500        }
501    }
502
503    fn fix_json(&self, content: &str) -> Result<String, LintError> {
504        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
505        if frontmatter_lines.is_empty() {
506            return Ok(content.to_string());
507        }
508
509        let keys = Self::extract_json_keys(&frontmatter_lines);
510
511        if keys.is_empty() || Self::are_keys_sorted(&keys) {
512            return Ok(content.to_string());
513        }
514
515        // Reconstruct JSON content including braces for parsing
516        let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
517
518        // Parse and re-serialize with sorted keys
519        match serde_json::from_str::<serde_json::Value>(&json_content) {
520            Ok(serde_json::Value::Object(map)) => {
521                // serde_json::Map preserves insertion order, so we need to rebuild
522                let mut sorted_map = serde_json::Map::new();
523                let mut keys: Vec<_> = map.keys().cloned().collect();
524                keys.sort_by_key(|a| a.to_lowercase());
525
526                for key in keys {
527                    if let Some(value) = map.get(&key) {
528                        sorted_map.insert(key, value.clone());
529                    }
530                }
531
532                match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
533                    Ok(sorted_json) => {
534                        let lines: Vec<&str> = content.lines().collect();
535                        let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
536
537                        // The pretty-printed JSON includes the outer braces
538                        // We need to format it properly for frontmatter
539                        let mut result = String::new();
540                        result.push_str(&sorted_json);
541
542                        if fm_end < lines.len() {
543                            result.push('\n');
544                            result.push_str(&lines[fm_end..].join("\n"));
545                        }
546
547                        Ok(result)
548                    }
549                    Err(_) => Ok(content.to_string()),
550                }
551            }
552            _ => Ok(content.to_string()),
553        }
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use crate::lint_context::LintContext;
561
562    /// Create an enabled rule for testing
563    fn create_enabled_rule() -> MD072FrontmatterKeySort {
564        MD072FrontmatterKeySort::from_config_struct(MD072Config { enabled: true })
565    }
566
567    // ==================== Config Tests ====================
568
569    #[test]
570    fn test_disabled_by_default() {
571        let rule = MD072FrontmatterKeySort::new();
572        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
573        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
574        let result = rule.check(&ctx).unwrap();
575
576        // Disabled by default, should return no warnings
577        assert!(result.is_empty());
578    }
579
580    #[test]
581    fn test_enabled_via_config() {
582        let rule = create_enabled_rule();
583        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
584        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
585        let result = rule.check(&ctx).unwrap();
586
587        // Enabled, should detect unsorted keys
588        assert_eq!(result.len(), 1);
589    }
590
591    // ==================== YAML Tests ====================
592
593    #[test]
594    fn test_no_frontmatter() {
595        let rule = create_enabled_rule();
596        let content = "# Heading\n\nContent.";
597        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
598        let result = rule.check(&ctx).unwrap();
599
600        assert!(result.is_empty());
601    }
602
603    #[test]
604    fn test_yaml_sorted_keys() {
605        let rule = create_enabled_rule();
606        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
607        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
608        let result = rule.check(&ctx).unwrap();
609
610        assert!(result.is_empty());
611    }
612
613    #[test]
614    fn test_yaml_unsorted_keys() {
615        let rule = create_enabled_rule();
616        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
617        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618        let result = rule.check(&ctx).unwrap();
619
620        assert_eq!(result.len(), 1);
621        assert!(result[0].message.contains("YAML"));
622        assert!(result[0].message.contains("not sorted"));
623        assert!(result[0].message.contains("author, date, title"));
624    }
625
626    #[test]
627    fn test_yaml_case_insensitive_sort() {
628        let rule = create_enabled_rule();
629        let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
630        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631        let result = rule.check(&ctx).unwrap();
632
633        // Author, date, Title should be considered sorted (case-insensitive)
634        assert!(result.is_empty());
635    }
636
637    #[test]
638    fn test_yaml_fix_sorts_keys() {
639        let rule = create_enabled_rule();
640        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
641        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
642        let fixed = rule.fix(&ctx).unwrap();
643
644        // Keys should be sorted
645        let author_pos = fixed.find("author:").unwrap();
646        let title_pos = fixed.find("title:").unwrap();
647        assert!(author_pos < title_pos);
648    }
649
650    #[test]
651    fn test_yaml_no_fix_with_comments() {
652        let rule = create_enabled_rule();
653        let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
654        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
655        let result = rule.check(&ctx).unwrap();
656
657        assert_eq!(result.len(), 1);
658        assert!(result[0].message.contains("Auto-fix unavailable"));
659        assert!(result[0].fix.is_none());
660
661        // Fix should not modify content
662        let fixed = rule.fix(&ctx).unwrap();
663        assert_eq!(fixed, content);
664    }
665
666    #[test]
667    fn test_yaml_single_key() {
668        let rule = create_enabled_rule();
669        let content = "---\ntitle: Test\n---\n\n# Heading";
670        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
671        let result = rule.check(&ctx).unwrap();
672
673        // Single key is always sorted
674        assert!(result.is_empty());
675    }
676
677    #[test]
678    fn test_yaml_nested_keys_ignored() {
679        let rule = create_enabled_rule();
680        // Only top-level keys are checked, nested keys are ignored
681        let content = "---\nauthor:\n  name: John\n  email: john@example.com\ntitle: Test\n---\n\n# Heading";
682        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683        let result = rule.check(&ctx).unwrap();
684
685        // author, title are sorted
686        assert!(result.is_empty());
687    }
688
689    #[test]
690    fn test_yaml_fix_idempotent() {
691        let rule = create_enabled_rule();
692        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
693        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694        let fixed_once = rule.fix(&ctx).unwrap();
695
696        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
697        let fixed_twice = rule.fix(&ctx2).unwrap();
698
699        assert_eq!(fixed_once, fixed_twice);
700    }
701
702    #[test]
703    fn test_yaml_complex_values() {
704        let rule = create_enabled_rule();
705        // Keys in sorted order: author, tags, title
706        let content =
707            "---\nauthor: John Doe\ntags:\n  - rust\n  - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
708        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
709        let result = rule.check(&ctx).unwrap();
710
711        // author, tags, title - sorted
712        assert!(result.is_empty());
713    }
714
715    // ==================== TOML Tests ====================
716
717    #[test]
718    fn test_toml_sorted_keys() {
719        let rule = create_enabled_rule();
720        let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
721        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722        let result = rule.check(&ctx).unwrap();
723
724        assert!(result.is_empty());
725    }
726
727    #[test]
728    fn test_toml_unsorted_keys() {
729        let rule = create_enabled_rule();
730        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
731        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
732        let result = rule.check(&ctx).unwrap();
733
734        assert_eq!(result.len(), 1);
735        assert!(result[0].message.contains("TOML"));
736        assert!(result[0].message.contains("not sorted"));
737    }
738
739    #[test]
740    fn test_toml_fix_sorts_keys() {
741        let rule = create_enabled_rule();
742        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
743        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744        let fixed = rule.fix(&ctx).unwrap();
745
746        // Keys should be sorted
747        let author_pos = fixed.find("author").unwrap();
748        let title_pos = fixed.find("title").unwrap();
749        assert!(author_pos < title_pos);
750    }
751
752    #[test]
753    fn test_toml_no_fix_with_comments() {
754        let rule = create_enabled_rule();
755        let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
756        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757        let result = rule.check(&ctx).unwrap();
758
759        assert_eq!(result.len(), 1);
760        assert!(result[0].message.contains("Auto-fix unavailable"));
761
762        // Fix should not modify content
763        let fixed = rule.fix(&ctx).unwrap();
764        assert_eq!(fixed, content);
765    }
766
767    // ==================== JSON Tests ====================
768
769    #[test]
770    fn test_json_sorted_keys() {
771        let rule = create_enabled_rule();
772        let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
773        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774        let result = rule.check(&ctx).unwrap();
775
776        assert!(result.is_empty());
777    }
778
779    #[test]
780    fn test_json_unsorted_keys() {
781        let rule = create_enabled_rule();
782        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
783        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784        let result = rule.check(&ctx).unwrap();
785
786        assert_eq!(result.len(), 1);
787        assert!(result[0].message.contains("JSON"));
788        assert!(result[0].message.contains("not sorted"));
789    }
790
791    #[test]
792    fn test_json_fix_sorts_keys() {
793        let rule = create_enabled_rule();
794        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
795        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796        let fixed = rule.fix(&ctx).unwrap();
797
798        // Keys should be sorted
799        let author_pos = fixed.find("author").unwrap();
800        let title_pos = fixed.find("title").unwrap();
801        assert!(author_pos < title_pos);
802    }
803
804    #[test]
805    fn test_json_always_fixable() {
806        let rule = create_enabled_rule();
807        // JSON has no comments, so should always be fixable
808        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
809        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
810        let result = rule.check(&ctx).unwrap();
811
812        assert_eq!(result.len(), 1);
813        assert!(result[0].fix.is_some()); // Always fixable
814        assert!(!result[0].message.contains("Auto-fix unavailable"));
815    }
816
817    // ==================== General Tests ====================
818
819    #[test]
820    fn test_empty_content() {
821        let rule = create_enabled_rule();
822        let content = "";
823        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
824        let result = rule.check(&ctx).unwrap();
825
826        assert!(result.is_empty());
827    }
828
829    #[test]
830    fn test_empty_frontmatter() {
831        let rule = create_enabled_rule();
832        let content = "---\n---\n\n# Heading";
833        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
834        let result = rule.check(&ctx).unwrap();
835
836        assert!(result.is_empty());
837    }
838
839    #[test]
840    fn test_toml_nested_tables_ignored() {
841        // Keys inside [extra] or [taxonomies] should NOT be checked
842        let rule = create_enabled_rule();
843        let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
844        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
845        let result = rule.check(&ctx).unwrap();
846
847        // Only top-level keys (title, sort_by) should be checked, not we_have_extra
848        assert_eq!(result.len(), 1);
849        assert!(result[0].message.contains("sort_by, title"));
850        assert!(!result[0].message.contains("we_have_extra"));
851    }
852
853    #[test]
854    fn test_toml_nested_taxonomies_ignored() {
855        // Keys inside [taxonomies] should NOT be checked
856        let rule = create_enabled_rule();
857        let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
858        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
859        let result = rule.check(&ctx).unwrap();
860
861        // Only top-level keys (title, date) should be checked
862        assert_eq!(result.len(), 1);
863        assert!(result[0].message.contains("date, title"));
864        assert!(!result[0].message.contains("categories"));
865        assert!(!result[0].message.contains("tags"));
866    }
867
868    // ==================== Edge Case Tests ====================
869
870    #[test]
871    fn test_yaml_unicode_keys() {
872        let rule = create_enabled_rule();
873        // Japanese keys should sort correctly
874        let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
875        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876        let result = rule.check(&ctx).unwrap();
877
878        // Should detect unsorted keys (あいう < タイトル < 日本語 in Unicode order)
879        assert_eq!(result.len(), 1);
880    }
881
882    #[test]
883    fn test_yaml_keys_with_special_characters() {
884        let rule = create_enabled_rule();
885        // Keys with dashes and underscores
886        let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
887        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888        let result = rule.check(&ctx).unwrap();
889
890        // my-key, my_key, mykey - should be sorted
891        assert!(result.is_empty());
892    }
893
894    #[test]
895    fn test_yaml_keys_with_numbers() {
896        let rule = create_enabled_rule();
897        let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
898        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
899        let result = rule.check(&ctx).unwrap();
900
901        // key1, key10, key2 - lexicographic order (1 < 10 < 2)
902        assert!(result.is_empty());
903    }
904
905    #[test]
906    fn test_yaml_multiline_string_block_literal() {
907        let rule = create_enabled_rule();
908        let content =
909            "---\ndescription: |\n  This is a\n  multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
910        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911        let result = rule.check(&ctx).unwrap();
912
913        // author, description, title - not sorted
914        assert_eq!(result.len(), 1);
915        assert!(result[0].message.contains("author, description, title"));
916    }
917
918    #[test]
919    fn test_yaml_multiline_string_folded() {
920        let rule = create_enabled_rule();
921        let content = "---\ndescription: >\n  This is a\n  folded string\nauthor: John\n---\n\n# Heading";
922        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923        let result = rule.check(&ctx).unwrap();
924
925        // author, description - not sorted
926        assert_eq!(result.len(), 1);
927    }
928
929    #[test]
930    fn test_yaml_fix_preserves_multiline_values() {
931        let rule = create_enabled_rule();
932        let content = "---\ntitle: Test\ndescription: |\n  Line 1\n  Line 2\n---\n\n# Heading";
933        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
934        let fixed = rule.fix(&ctx).unwrap();
935
936        // description should come before title
937        let desc_pos = fixed.find("description").unwrap();
938        let title_pos = fixed.find("title").unwrap();
939        assert!(desc_pos < title_pos);
940    }
941
942    #[test]
943    fn test_yaml_quoted_keys() {
944        let rule = create_enabled_rule();
945        let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
946        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
947        let result = rule.check(&ctx).unwrap();
948
949        // quoted-key should sort before unquoted
950        assert!(result.is_empty());
951    }
952
953    #[test]
954    fn test_yaml_duplicate_keys() {
955        // YAML allows duplicate keys (last one wins), but we should still sort
956        let rule = create_enabled_rule();
957        let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
958        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
959        let result = rule.check(&ctx).unwrap();
960
961        // Should still check sorting (title, author, title is not sorted)
962        assert_eq!(result.len(), 1);
963    }
964
965    #[test]
966    fn test_toml_inline_table() {
967        let rule = create_enabled_rule();
968        let content =
969            "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
970        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
971        let result = rule.check(&ctx).unwrap();
972
973        // author, title - sorted
974        assert!(result.is_empty());
975    }
976
977    #[test]
978    fn test_toml_array_of_tables() {
979        let rule = create_enabled_rule();
980        let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
981        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982        let result = rule.check(&ctx).unwrap();
983
984        // Only top-level keys (title, date) checked - date < title, so unsorted
985        assert_eq!(result.len(), 1);
986        assert!(result[0].message.contains("date, title"));
987    }
988
989    #[test]
990    fn test_json_nested_objects() {
991        let rule = create_enabled_rule();
992        let content = "{\n\"author\": {\n  \"name\": \"John\",\n  \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
993        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
994        let result = rule.check(&ctx).unwrap();
995
996        // Only top-level keys (author, title) checked - sorted
997        assert!(result.is_empty());
998    }
999
1000    #[test]
1001    fn test_json_arrays() {
1002        let rule = create_enabled_rule();
1003        let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1004        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005        let result = rule.check(&ctx).unwrap();
1006
1007        // author, tags - not sorted (tags comes first)
1008        assert_eq!(result.len(), 1);
1009    }
1010
1011    #[test]
1012    fn test_fix_preserves_content_after_frontmatter() {
1013        let rule = create_enabled_rule();
1014        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1015        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1016        let fixed = rule.fix(&ctx).unwrap();
1017
1018        // Verify content after frontmatter is preserved
1019        assert!(fixed.contains("# Heading"));
1020        assert!(fixed.contains("Paragraph 1."));
1021        assert!(fixed.contains("- List item"));
1022        assert!(fixed.contains("- Another item"));
1023    }
1024
1025    #[test]
1026    fn test_fix_yaml_produces_valid_yaml() {
1027        let rule = create_enabled_rule();
1028        let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1029        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1030        let fixed = rule.fix(&ctx).unwrap();
1031
1032        // The fixed output should be parseable as YAML
1033        // Extract frontmatter lines
1034        let lines: Vec<&str> = fixed.lines().collect();
1035        let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1036        let fm_content: String = lines[1..fm_end].join("\n");
1037
1038        // Should parse without error
1039        let parsed: Result<serde_yml::Value, _> = serde_yml::from_str(&fm_content);
1040        assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1041    }
1042
1043    #[test]
1044    fn test_fix_toml_produces_valid_toml() {
1045        let rule = create_enabled_rule();
1046        let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1047        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1048        let fixed = rule.fix(&ctx).unwrap();
1049
1050        // Extract frontmatter
1051        let lines: Vec<&str> = fixed.lines().collect();
1052        let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1053        let fm_content: String = lines[1..fm_end].join("\n");
1054
1055        // Should parse without error
1056        let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1057        assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1058    }
1059
1060    #[test]
1061    fn test_fix_json_produces_valid_json() {
1062        let rule = create_enabled_rule();
1063        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1064        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065        let fixed = rule.fix(&ctx).unwrap();
1066
1067        // Extract JSON frontmatter (everything up to blank line)
1068        let json_end = fixed.find("\n\n").unwrap();
1069        let json_content = &fixed[..json_end];
1070
1071        // Should parse without error
1072        let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1073        assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1074    }
1075
1076    #[test]
1077    fn test_many_keys_performance() {
1078        let rule = create_enabled_rule();
1079        // Generate frontmatter with 100 keys
1080        let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1081        keys.reverse(); // Make them unsorted
1082        let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1083
1084        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1085        let result = rule.check(&ctx).unwrap();
1086
1087        // Should detect unsorted keys
1088        assert_eq!(result.len(), 1);
1089    }
1090
1091    #[test]
1092    fn test_yaml_empty_value() {
1093        let rule = create_enabled_rule();
1094        let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1095        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1096        let result = rule.check(&ctx).unwrap();
1097
1098        // author, title - not sorted
1099        assert_eq!(result.len(), 1);
1100    }
1101
1102    #[test]
1103    fn test_yaml_null_value() {
1104        let rule = create_enabled_rule();
1105        let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1106        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1107        let result = rule.check(&ctx).unwrap();
1108
1109        assert_eq!(result.len(), 1);
1110    }
1111
1112    #[test]
1113    fn test_yaml_boolean_values() {
1114        let rule = create_enabled_rule();
1115        let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1116        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117        let result = rule.check(&ctx).unwrap();
1118
1119        // author, draft - not sorted
1120        assert_eq!(result.len(), 1);
1121    }
1122
1123    #[test]
1124    fn test_toml_boolean_values() {
1125        let rule = create_enabled_rule();
1126        let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1127        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1128        let result = rule.check(&ctx).unwrap();
1129
1130        assert_eq!(result.len(), 1);
1131    }
1132
1133    #[test]
1134    fn test_yaml_list_at_top_level() {
1135        let rule = create_enabled_rule();
1136        let content = "---\ntags:\n  - rust\n  - markdown\nauthor: John\n---\n\n# Heading";
1137        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1138        let result = rule.check(&ctx).unwrap();
1139
1140        // author, tags - not sorted (tags comes first)
1141        assert_eq!(result.len(), 1);
1142    }
1143
1144    #[test]
1145    fn test_three_keys_all_orderings() {
1146        let rule = create_enabled_rule();
1147
1148        // Test all 6 permutations of a, b, c
1149        let orderings = [
1150            ("a, b, c", "---\na: 1\nb: 2\nc: 3\n---\n\n# H", true),  // sorted
1151            ("a, c, b", "---\na: 1\nc: 3\nb: 2\n---\n\n# H", false), // unsorted
1152            ("b, a, c", "---\nb: 2\na: 1\nc: 3\n---\n\n# H", false), // unsorted
1153            ("b, c, a", "---\nb: 2\nc: 3\na: 1\n---\n\n# H", false), // unsorted
1154            ("c, a, b", "---\nc: 3\na: 1\nb: 2\n---\n\n# H", false), // unsorted
1155            ("c, b, a", "---\nc: 3\nb: 2\na: 1\n---\n\n# H", false), // unsorted
1156        ];
1157
1158        for (name, content, should_pass) in orderings {
1159            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1160            let result = rule.check(&ctx).unwrap();
1161            assert_eq!(
1162                result.is_empty(),
1163                should_pass,
1164                "Ordering {name} should {} pass",
1165                if should_pass { "" } else { "not" }
1166            );
1167        }
1168    }
1169
1170    #[test]
1171    fn test_crlf_line_endings() {
1172        let rule = create_enabled_rule();
1173        let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1174        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1175        let result = rule.check(&ctx).unwrap();
1176
1177        // Should detect unsorted keys with CRLF
1178        assert_eq!(result.len(), 1);
1179    }
1180
1181    #[test]
1182    fn test_json_escaped_quotes_in_keys() {
1183        let rule = create_enabled_rule();
1184        // This is technically invalid JSON but tests regex robustness
1185        let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1186        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187        let result = rule.check(&ctx).unwrap();
1188
1189        // key, normal - not sorted
1190        assert_eq!(result.len(), 1);
1191    }
1192}