Skip to main content

rumdl_lib/rules/
md072_frontmatter_key_sort.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::rules::front_matter_utils::{FrontMatterType, FrontMatterUtils};
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::sync::LazyLock;
7
8/// Pre-compiled regex for extracting JSON keys
9static JSON_KEY_PATTERN: LazyLock<Regex> =
10    LazyLock::new(|| Regex::new(r#"^\s*"([^"]+)"\s*:"#).expect("Invalid JSON key regex"));
11
12/// Configuration for MD072 (Frontmatter key sort)
13///
14/// This rule is disabled by default (opt-in) because key sorting
15/// is an opinionated style choice. Many projects prefer semantic ordering.
16#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
17pub struct MD072Config {
18    /// Whether this rule is enabled (default: false - opt-in rule)
19    #[serde(default)]
20    pub enabled: bool,
21
22    /// Custom key order. Keys listed here will be sorted in this order.
23    /// Keys not in this list will be sorted alphabetically after the specified keys.
24    /// If not set, all keys are sorted alphabetically (case-insensitive).
25    ///
26    /// Example: `key_order = ["title", "date", "author", "tags"]`
27    #[serde(default, alias = "key-order")]
28    pub key_order: Option<Vec<String>>,
29
30    /// Keys that must be present in the frontmatter. Each missing key is
31    /// reported, matched case-insensitively against top-level keys (like
32    /// `key_order`). Independent of `key_order`: you can order many keys while
33    /// requiring only a few. These warnings carry no fix because rumdl cannot
34    /// invent meaningful values for missing keys.
35    ///
36    /// Only applies to files that have frontmatter; requiring frontmatter to
37    /// exist at all is out of scope for this rule.
38    ///
39    /// Example: `required_keys = ["title", "date"]`
40    #[serde(default, alias = "required-keys")]
41    pub required_keys: Vec<String>,
42}
43
44impl RuleConfig for MD072Config {
45    const RULE_NAME: &'static str = "MD072";
46}
47
48/// Rule MD072: Frontmatter key sort
49///
50/// Ensures frontmatter keys are sorted alphabetically.
51/// Supports YAML, TOML, and JSON frontmatter formats.
52/// Auto-fix is only available when frontmatter contains no comments (YAML/TOML).
53/// JSON frontmatter is always auto-fixable since JSON has no comments.
54///
55/// **Note**: This rule is disabled by default because alphabetical key sorting
56/// is an opinionated style choice. Many projects prefer semantic ordering
57/// (title first, date second, etc.) rather than alphabetical.
58///
59/// See [docs/md072.md](../../docs/md072.md) for full documentation.
60#[derive(Clone, Default)]
61pub struct MD072FrontmatterKeySort {
62    config: MD072Config,
63}
64
65impl MD072FrontmatterKeySort {
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    /// Create from a config struct
71    pub fn from_config_struct(config: MD072Config) -> Self {
72        Self { config }
73    }
74
75    /// Check if frontmatter contains comments (YAML/TOML use #)
76    fn has_comments(frontmatter_lines: &[&str]) -> bool {
77        frontmatter_lines.iter().any(|line| line.trim_start().starts_with('#'))
78    }
79
80    /// Extract top-level keys from YAML frontmatter
81    fn extract_yaml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
82        let mut keys = Vec::new();
83
84        for (idx, line) in frontmatter_lines.iter().enumerate() {
85            // Top-level keys have no leading whitespace and contain a colon
86            // (searched outside a leading quoted key, which may itself
87            // contain one, e.g. "og:title").
88            if !line.starts_with(' ')
89                && !line.starts_with('\t')
90                && let Some(colon_pos) = Self::separator_pos_outside_quoted_key(line, ':')
91            {
92                let raw = line[..colon_pos].trim();
93                if !raw.is_empty() && !raw.starts_with('#') {
94                    // Sort by the key's content, not by surrounding quote
95                    // characters: a quoted key like "zebra" must compare as
96                    // `zebra`, not as `"zebra` (which would always sort before
97                    // any unquoted key because '"' is ASCII 34).
98                    let key = raw
99                        .strip_prefix('"')
100                        .and_then(|k| k.strip_suffix('"'))
101                        .or_else(|| raw.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
102                        .unwrap_or(raw);
103                    keys.push((idx, key.to_string()));
104                }
105            }
106        }
107
108        keys
109    }
110
111    /// Extract top-level keys from TOML frontmatter
112    fn extract_toml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
113        let mut keys = Vec::new();
114
115        for (idx, line) in frontmatter_lines.iter().enumerate() {
116            let trimmed = line.trim();
117            // Skip comments and empty lines
118            if trimmed.is_empty() || trimmed.starts_with('#') {
119                continue;
120            }
121            // Stop at table headers like [section] - everything after is nested
122            if trimmed.starts_with('[') {
123                break;
124            }
125            // Top-level keys have no leading whitespace and contain =
126            // (searched outside a leading quoted key, which may itself
127            // contain one, e.g. "a=b").
128            if !line.starts_with(' ')
129                && !line.starts_with('\t')
130                && let Some(eq_pos) = Self::separator_pos_outside_quoted_key(line, '=')
131            {
132                let raw = line[..eq_pos].trim();
133                if !raw.is_empty() {
134                    // Compare by the key's content, not the surrounding quote
135                    // characters: a TOML basic (`"key"`) or literal (`'key'`)
136                    // quoted key must sort and match like its bare form ('"'
137                    // is ASCII 34 and would sort before any unquoted key).
138                    let key = raw
139                        .strip_prefix('"')
140                        .and_then(|k| k.strip_suffix('"'))
141                        .or_else(|| raw.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
142                        .unwrap_or(raw);
143                    keys.push((idx, key.to_string()));
144                }
145            }
146        }
147
148        keys
149    }
150
151    /// Extract top-level keys from JSON frontmatter in order of appearance
152    fn extract_json_keys(frontmatter_lines: &[&str]) -> Vec<String> {
153        // Extract keys from raw JSON text to preserve original order
154        // serde_json::Map uses BTreeMap which sorts keys, so we parse manually
155        // Only extract keys at depth 0 relative to the content (top-level inside the outer object)
156        // Note: frontmatter_lines excludes the opening `{`, so we start at depth 0
157        let mut keys = Vec::new();
158        let mut depth: usize = 0;
159
160        for line in frontmatter_lines {
161            // Track depth before checking for keys on this line
162            let line_start_depth = depth;
163
164            // Count braces and brackets to track nesting, skipping those inside strings
165            let mut in_string = false;
166            let mut prev_backslash = false;
167            for ch in line.chars() {
168                if in_string {
169                    if ch == '"' && !prev_backslash {
170                        in_string = false;
171                    }
172                    prev_backslash = ch == '\\' && !prev_backslash;
173                } else {
174                    match ch {
175                        '"' => in_string = true,
176                        '{' | '[' => depth += 1,
177                        '}' | ']' => depth = depth.saturating_sub(1),
178                        _ => {}
179                    }
180                    prev_backslash = false;
181                }
182            }
183
184            // Only extract keys at depth 0 (top-level, since opening brace is excluded)
185            if line_start_depth == 0
186                && let Some(captures) = JSON_KEY_PATTERN.captures(line)
187                && let Some(key_match) = captures.get(1)
188            {
189                keys.push(key_match.as_str().to_string());
190            }
191        }
192
193        keys
194    }
195
196    /// Get the sort position for a key based on custom key_order or alphabetical fallback.
197    /// Keys in key_order get their index (0, 1, 2...), keys not in key_order get
198    /// a high value so they sort after, with alphabetical sub-sorting.
199    fn key_sort_position(key: &str, key_order: Option<&[String]>) -> (usize, String) {
200        if let Some(order) = key_order {
201            // Find position in custom order (case-insensitive match)
202            let key_lower = key.to_lowercase();
203            for (idx, ordered_key) in order.iter().enumerate() {
204                if ordered_key.to_lowercase() == key_lower {
205                    return (idx, key_lower);
206                }
207            }
208            // Not in custom order - sort after with alphabetical
209            (usize::MAX, key_lower)
210        } else {
211            // No custom order - pure alphabetical
212            (0, key.to_lowercase())
213        }
214    }
215
216    /// Find the first pair of keys that are out of order
217    /// Returns (out_of_place_key, should_come_after_key)
218    fn find_first_unsorted_pair<'a>(keys: &'a [String], key_order: Option<&[String]>) -> Option<(&'a str, &'a str)> {
219        for i in 1..keys.len() {
220            let pos_curr = Self::key_sort_position(&keys[i], key_order);
221            let pos_prev = Self::key_sort_position(&keys[i - 1], key_order);
222            if pos_curr < pos_prev {
223                return Some((&keys[i], &keys[i - 1]));
224            }
225        }
226        None
227    }
228
229    /// Find the first pair of indexed keys that are out of order
230    /// Returns (out_of_place_key, should_come_after_key)
231    fn find_first_unsorted_indexed_pair<'a>(
232        keys: &'a [(usize, String)],
233        key_order: Option<&[String]>,
234    ) -> Option<(usize, &'a str, &'a str)> {
235        for i in 1..keys.len() {
236            let pos_curr = Self::key_sort_position(&keys[i].1, key_order);
237            let pos_prev = Self::key_sort_position(&keys[i - 1].1, key_order);
238            if pos_curr < pos_prev {
239                return Some((keys[i].0, &keys[i].1, &keys[i - 1].1));
240            }
241        }
242        None
243    }
244
245    /// Check if keys are sorted according to key_order (or alphabetically if None)
246    fn are_keys_sorted(keys: &[String], key_order: Option<&[String]>) -> bool {
247        Self::find_first_unsorted_pair(keys, key_order).is_none()
248    }
249
250    /// Check if indexed keys are sorted according to key_order (or alphabetically if None)
251    fn are_indexed_keys_sorted(keys: &[(usize, String)], key_order: Option<&[String]>) -> bool {
252        Self::find_first_unsorted_indexed_pair(keys, key_order).is_none()
253    }
254
255    /// Sort keys according to key_order, with alphabetical fallback for unlisted keys
256    fn sort_keys_by_order(keys: &mut [(String, Vec<&str>)], key_order: Option<&[String]>) {
257        keys.sort_by(|a, b| {
258            let pos_a = Self::key_sort_position(&a.0, key_order);
259            let pos_b = Self::key_sort_position(&b.0, key_order);
260            pos_a.cmp(&pos_b)
261        });
262    }
263
264    /// Byte position of `separator` outside a leading quoted key: a quoted
265    /// key (`"og:title":`, `"a=b" =`) may contain the separator character,
266    /// so the search starts after the closing quote.
267    fn separator_pos_outside_quoted_key(line: &str, separator: char) -> Option<usize> {
268        let after_quote = if let Some(rest) = line.strip_prefix('"') {
269            rest.find('"').map(|i| i + 2)
270        } else if let Some(rest) = line.strip_prefix('\'') {
271            rest.find('\'').map(|i| i + 2)
272        } else {
273            None
274        };
275        match after_quote {
276            Some(start) => line[start..].find(separator).map(|i| start + i),
277            None => line.find(separator),
278        }
279    }
280
281    /// The top-level key a raw TOML key expression defines. A quoted key is
282    /// atomic (`"a.b"` defines `a.b`); otherwise the root of a dotted path
283    /// (`params.seo` defines `params`).
284    fn toml_root_key(raw: &str) -> &str {
285        if let Some(rest) = raw.strip_prefix('"') {
286            if let Some(end) = rest.find('"') {
287                return &rest[..end];
288            }
289        } else if let Some(rest) = raw.strip_prefix('\'')
290            && let Some(end) = rest.find('\'')
291        {
292            return &rest[..end];
293        }
294        raw.split('.').next().unwrap_or(raw).trim()
295    }
296
297    /// Every top-level key the TOML frontmatter defines, for the presence
298    /// check. Broader than `extract_toml_keys` (which is scoped to what the
299    /// sort check orders): dotted assignments count by their root
300    /// (`params.seo = true` defines `params`), and table headers count too
301    /// (`[taxonomies]`, `[[authors]]`, `[params.seo] # comment`). Assignments
302    /// after the first table header are nested inside that table, not
303    /// top-level.
304    fn extract_toml_presence_keys(frontmatter_lines: &[&str]) -> Vec<String> {
305        let mut keys = Vec::new();
306        let mut in_tables = false;
307
308        for line in frontmatter_lines {
309            let trimmed = line.trim();
310            if trimmed.is_empty() || trimmed.starts_with('#') {
311                continue;
312            }
313            if trimmed.starts_with('[') {
314                in_tables = true;
315                // Take the bracketed expression only: a header may carry an
316                // inline comment after the closing bracket.
317                let inner = trimmed
318                    .strip_prefix("[[")
319                    .and_then(|s| s.split_once("]]").map(|(inner, _)| inner))
320                    .or_else(|| {
321                        trimmed
322                            .strip_prefix('[')
323                            .and_then(|s| s.split_once(']').map(|(inner, _)| inner))
324                    });
325                if let Some(inner) = inner {
326                    let key = Self::toml_root_key(inner.trim());
327                    if !key.is_empty() {
328                        keys.push(key.to_string());
329                    }
330                }
331                continue;
332            }
333            if !in_tables
334                && !line.starts_with(' ')
335                && !line.starts_with('\t')
336                && let Some(eq_pos) = Self::separator_pos_outside_quoted_key(line, '=')
337            {
338                let key = Self::toml_root_key(line[..eq_pos].trim());
339                if !key.is_empty() {
340                    keys.push(key.to_string());
341                }
342            }
343        }
344
345        keys
346    }
347
348    /// Parse the JSON frontmatter and return its top-level keys, in no
349    /// particular order (`serde_json::Map` sorts them). `None` when the JSON
350    /// does not parse as an object.
351    fn parse_json_top_level_keys(frontmatter_lines: &[&str]) -> Option<Vec<String>> {
352        let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
353        match serde_json::from_str::<serde_json::Value>(&json_content) {
354            Ok(serde_json::Value::Object(map)) => Some(map.keys().cloned().collect()),
355            _ => None,
356        }
357    }
358
359    /// Warnings for configured required keys absent from the frontmatter.
360    ///
361    /// Matching is case-insensitive against top-level keys, consistent with how
362    /// `key_order` matches. The warning spans the whole frontmatter block (the
363    /// opening fence through the closing fence): the absence belongs to the
364    /// block, not to any line in it, and the range lets an inline disable
365    /// comment anywhere inside the frontmatter suppress the warning, like it
366    /// does for the sort warnings. No fix is attached because rumdl cannot
367    /// invent a meaningful value.
368    fn missing_required_key_warnings(
369        &self,
370        present_keys: &[String],
371        format: &str,
372        fence_len: usize,
373        fm_end_line: usize,
374    ) -> Vec<LintWarning> {
375        if self.config.required_keys.is_empty() {
376            return Vec::new();
377        }
378
379        let present: Vec<String> = present_keys.iter().map(|k| k.to_lowercase()).collect();
380        self.config
381            .required_keys
382            .iter()
383            .filter(|required| !present.contains(&required.to_lowercase()))
384            .map(|required| LintWarning {
385                rule_name: Some(self.name().to_string()),
386                message: format!("{format} frontmatter is missing required key '{required}'"),
387                line: 1,
388                column: 1,
389                end_line: fm_end_line.max(1),
390                end_column: fence_len + 1,
391                severity: Severity::Warning,
392                fix: None,
393            })
394            .collect()
395    }
396}
397
398impl Rule for MD072FrontmatterKeySort {
399    fn name(&self) -> &'static str {
400        "MD072"
401    }
402
403    fn description(&self) -> &'static str {
404        "Frontmatter keys should be sorted alphabetically"
405    }
406
407    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
408        let content = ctx.content;
409        let mut warnings = Vec::new();
410
411        if content.is_empty() {
412            return Ok(warnings);
413        }
414
415        let fm_type = FrontMatterUtils::detect_front_matter_type(content);
416
417        match fm_type {
418            FrontMatterType::Yaml => {
419                let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
420                let keys = Self::extract_yaml_keys(&frontmatter_lines);
421
422                let key_names: Vec<String> = keys.iter().map(|(_, key)| key.clone()).collect();
423                warnings.extend(self.missing_required_key_warnings(&key_names, "YAML", 3, ctx.front_matter_end_line()));
424
425                if frontmatter_lines.is_empty() {
426                    return Ok(warnings);
427                }
428
429                let key_order = self.config.key_order.as_deref();
430                let Some((key_idx, out_of_place, should_come_after)) =
431                    Self::find_first_unsorted_indexed_pair(&keys, key_order)
432                else {
433                    return Ok(warnings);
434                };
435                // key_idx is relative to frontmatter_lines; +2 for 1-indexing and the opening ---
436                let key_line = key_idx + 2;
437
438                let has_comments = Self::has_comments(&frontmatter_lines);
439
440                let fix = if has_comments {
441                    None
442                } else {
443                    // Compute the actual fix: full content replacement
444                    let fixed_content = self.fix_yaml(content, ctx.front_matter_end_line());
445                    if fixed_content != content {
446                        Some(Fix::new(0..content.len(), fixed_content))
447                    } else {
448                        None
449                    }
450                };
451
452                let message = if has_comments {
453                    format!(
454                        "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
455                    )
456                } else {
457                    format!(
458                        "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
459                    )
460                };
461
462                // out_of_place has surrounding quotes stripped for sorting, so
463                // span the raw key (quotes included) as it appears on the line.
464                let end_column = frontmatter_lines
465                    .get(key_idx)
466                    .and_then(|line| {
467                        Self::separator_pos_outside_quoted_key(line, ':')
468                            .map(|pos| line[..pos].trim().chars().count() + 1)
469                    })
470                    .unwrap_or(out_of_place.chars().count() + 1);
471
472                warnings.push(LintWarning {
473                    rule_name: Some(self.name().to_string()),
474                    message,
475                    line: key_line,
476                    column: 1,
477                    end_line: key_line,
478                    end_column,
479                    severity: Severity::Warning,
480                    fix,
481                });
482            }
483            FrontMatterType::Toml => {
484                let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
485                let keys = Self::extract_toml_keys(&frontmatter_lines);
486
487                // Presence uses a broader extraction than the sort check:
488                // table headers and the roots of dotted assignments define
489                // top-level keys too.
490                let key_names = Self::extract_toml_presence_keys(&frontmatter_lines);
491                warnings.extend(self.missing_required_key_warnings(&key_names, "TOML", 3, ctx.front_matter_end_line()));
492
493                if frontmatter_lines.is_empty() {
494                    return Ok(warnings);
495                }
496
497                let key_order = self.config.key_order.as_deref();
498                let Some((key_idx, out_of_place, should_come_after)) =
499                    Self::find_first_unsorted_indexed_pair(&keys, key_order)
500                else {
501                    return Ok(warnings);
502                };
503                let key_line = key_idx + 2;
504
505                let has_comments = Self::has_comments(&frontmatter_lines);
506
507                let fix = if has_comments {
508                    None
509                } else {
510                    // Compute the actual fix: full content replacement
511                    let fixed_content = self.fix_toml(content, ctx.front_matter_end_line());
512                    if fixed_content != content {
513                        Some(Fix::new(0..content.len(), fixed_content))
514                    } else {
515                        None
516                    }
517                };
518
519                let message = if has_comments {
520                    format!(
521                        "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
522                    )
523                } else {
524                    format!(
525                        "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
526                    )
527                };
528
529                // out_of_place has surrounding quotes stripped for sorting, so
530                // span the raw key (quotes included) as it appears on the line.
531                let end_column = frontmatter_lines
532                    .get(key_idx)
533                    .and_then(|line| {
534                        Self::separator_pos_outside_quoted_key(line, '=')
535                            .map(|pos| line[..pos].trim().chars().count() + 1)
536                    })
537                    .unwrap_or(out_of_place.chars().count() + 1);
538
539                warnings.push(LintWarning {
540                    rule_name: Some(self.name().to_string()),
541                    message,
542                    line: key_line,
543                    column: 1,
544                    end_line: key_line,
545                    end_column,
546                    severity: Severity::Warning,
547                    fix,
548                });
549            }
550            FrontMatterType::Json => {
551                let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
552                let keys = Self::extract_json_keys(&frontmatter_lines);
553
554                // Presence is checked against a real JSON parse: the line-based
555                // extractor preserves document order for the sort check but
556                // captures only the first key per line, and a key it misses
557                // would be a false "missing required key". Order is irrelevant
558                // for presence, so the parsed object is authoritative; fall
559                // back to the line-based list when the JSON does not parse.
560                let parsed_keys = Self::parse_json_top_level_keys(&frontmatter_lines);
561                warnings.extend(self.missing_required_key_warnings(
562                    parsed_keys.as_deref().unwrap_or(&keys),
563                    "JSON",
564                    1,
565                    ctx.front_matter_end_line(),
566                ));
567
568                if frontmatter_lines.is_empty() {
569                    return Ok(warnings);
570                }
571
572                let key_order = self.config.key_order.as_deref();
573                let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_pair(&keys, key_order) else {
574                    return Ok(warnings);
575                };
576
577                // Compute the actual fix: full content replacement
578                let fixed_content = self.fix_json(content, ctx.front_matter_end_line());
579                let fix = if fixed_content != content {
580                    Some(Fix::new(0..content.len(), fixed_content))
581                } else {
582                    None
583                };
584
585                let message = format!(
586                    "JSON frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
587                );
588
589                warnings.push(LintWarning {
590                    rule_name: Some(self.name().to_string()),
591                    message,
592                    line: 2,
593                    column: 1,
594                    end_line: 2,
595                    end_column: out_of_place.len() + 1,
596                    severity: Severity::Warning,
597                    fix,
598                });
599            }
600            _ => {
601                // No frontmatter or malformed - skip
602            }
603        }
604
605        Ok(warnings)
606    }
607
608    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
609        let content = ctx.content;
610
611        // Skip fix if rule is disabled via inline config at the frontmatter region (line 2)
612        if ctx.is_rule_disabled(self.name(), 2) {
613            return Ok(content.to_string());
614        }
615
616        let fm_type = FrontMatterUtils::detect_front_matter_type(content);
617
618        let fm_end = ctx.front_matter_end_line();
619        Ok(match fm_type {
620            FrontMatterType::Yaml => self.fix_yaml(content, fm_end),
621            FrontMatterType::Toml => self.fix_toml(content, fm_end),
622            FrontMatterType::Json => self.fix_json(content, fm_end),
623            _ => content.to_string(),
624        })
625    }
626
627    fn category(&self) -> RuleCategory {
628        RuleCategory::FrontMatter
629    }
630
631    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
632        ctx.content.is_empty()
633            || !ctx.content.starts_with("---") && !ctx.content.starts_with("+++") && !ctx.content.starts_with('{')
634    }
635
636    fn as_any(&self) -> &dyn std::any::Any {
637        self
638    }
639
640    crate::impl_rule_config_methods!(MD072Config, nullable);
641}
642
643impl MD072FrontmatterKeySort {
644    /// Restore the original document's trailing newline. The fix functions
645    /// rebuild content via `lines()` + `join("\n")`, which never re-emits a
646    /// final newline, so without this a file ending in `\n` would lose it on
647    /// every fix (a dirty, non-idempotent diff).
648    fn preserve_trailing_newline(original: &str, mut result: String) -> String {
649        if original.ends_with('\n') && !result.ends_with('\n') {
650            result.push('\n');
651        }
652        result
653    }
654
655    fn fix_yaml(&self, content: &str, fm_end: usize) -> String {
656        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
657        if frontmatter_lines.is_empty() {
658            return content.to_string();
659        }
660
661        // Cannot fix if comments present
662        if Self::has_comments(&frontmatter_lines) {
663            return content.to_string();
664        }
665
666        let keys = Self::extract_yaml_keys(&frontmatter_lines);
667        let key_order = self.config.key_order.as_deref();
668        if Self::are_indexed_keys_sorted(&keys, key_order) {
669            return content.to_string();
670        }
671
672        // Line-based reordering to preserve original formatting (indentation, etc.)
673        // Each key owns all lines until the next top-level key
674        let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
675
676        for (i, (line_idx, key)) in keys.iter().enumerate() {
677            let start = *line_idx;
678            let end = if i + 1 < keys.len() {
679                keys[i + 1].0
680            } else {
681                frontmatter_lines.len()
682            };
683
684            let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
685            key_blocks.push((key.clone(), block_lines));
686        }
687
688        // Sort by key_order, with alphabetical fallback for unlisted keys
689        Self::sort_keys_by_order(&mut key_blocks, key_order);
690
691        // Reassemble frontmatter
692        let content_lines: Vec<&str> = content.lines().collect();
693
694        let mut result = String::new();
695        result.push_str("---\n");
696        for (_, lines) in &key_blocks {
697            for line in lines {
698                result.push_str(line);
699                result.push('\n');
700            }
701        }
702        result.push_str("---");
703
704        if fm_end < content_lines.len() {
705            result.push('\n');
706            result.push_str(&content_lines[fm_end..].join("\n"));
707        }
708
709        Self::preserve_trailing_newline(content, result)
710    }
711
712    fn fix_toml(&self, content: &str, fm_end: usize) -> String {
713        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
714        if frontmatter_lines.is_empty() {
715            return content.to_string();
716        }
717
718        // Cannot fix if comments present
719        if Self::has_comments(&frontmatter_lines) {
720            return content.to_string();
721        }
722
723        let keys = Self::extract_toml_keys(&frontmatter_lines);
724        let key_order = self.config.key_order.as_deref();
725        if Self::are_indexed_keys_sorted(&keys, key_order) {
726            return content.to_string();
727        }
728
729        // Line-based reordering to preserve original formatting
730        // Each key owns all lines until the next top-level key
731        let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
732
733        for (i, (line_idx, key)) in keys.iter().enumerate() {
734            let start = *line_idx;
735            let end = if i + 1 < keys.len() {
736                keys[i + 1].0
737            } else {
738                frontmatter_lines.len()
739            };
740
741            let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
742            key_blocks.push((key.clone(), block_lines));
743        }
744
745        // Sort by key_order, with alphabetical fallback for unlisted keys
746        Self::sort_keys_by_order(&mut key_blocks, key_order);
747
748        // Reassemble frontmatter
749        let content_lines: Vec<&str> = content.lines().collect();
750
751        let mut result = String::new();
752        result.push_str("+++\n");
753        for (_, lines) in &key_blocks {
754            for line in lines {
755                result.push_str(line);
756                result.push('\n');
757            }
758        }
759        result.push_str("+++");
760
761        if fm_end < content_lines.len() {
762            result.push('\n');
763            result.push_str(&content_lines[fm_end..].join("\n"));
764        }
765
766        Self::preserve_trailing_newline(content, result)
767    }
768
769    fn fix_json(&self, content: &str, fm_end: usize) -> String {
770        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
771        if frontmatter_lines.is_empty() {
772            return content.to_string();
773        }
774
775        let keys = Self::extract_json_keys(&frontmatter_lines);
776        let key_order = self.config.key_order.as_deref();
777
778        if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
779            return content.to_string();
780        }
781
782        // Reconstruct JSON content including braces for parsing
783        let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
784
785        // Parse and re-serialize with sorted keys
786        match serde_json::from_str::<serde_json::Value>(&json_content) {
787            Ok(serde_json::Value::Object(map)) => {
788                // Sort keys according to key_order, with alphabetical fallback
789                let mut sorted_map = serde_json::Map::new();
790                let mut keys: Vec<_> = map.keys().cloned().collect();
791                keys.sort_by(|a, b| {
792                    let pos_a = Self::key_sort_position(a, key_order);
793                    let pos_b = Self::key_sort_position(b, key_order);
794                    pos_a.cmp(&pos_b)
795                });
796
797                for key in keys {
798                    if let Some(value) = map.get(&key) {
799                        sorted_map.insert(key, value.clone());
800                    }
801                }
802
803                match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
804                    Ok(sorted_json) => {
805                        let lines: Vec<&str> = content.lines().collect();
806
807                        // The pretty-printed JSON includes the outer braces
808                        // We need to format it properly for frontmatter
809                        let mut result = String::new();
810                        result.push_str(&sorted_json);
811
812                        if fm_end < lines.len() {
813                            result.push('\n');
814                            result.push_str(&lines[fm_end..].join("\n"));
815                        }
816
817                        Self::preserve_trailing_newline(content, result)
818                    }
819                    Err(_) => content.to_string(),
820                }
821            }
822            _ => content.to_string(),
823        }
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use crate::lint_context::LintContext;
831
832    /// Create an enabled rule for testing (alphabetical sort)
833    fn create_enabled_rule() -> MD072FrontmatterKeySort {
834        MD072FrontmatterKeySort::from_config_struct(MD072Config {
835            enabled: true,
836            ..Default::default()
837        })
838    }
839
840    /// Create an enabled rule with custom key order for testing
841    fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
842        MD072FrontmatterKeySort::from_config_struct(MD072Config {
843            enabled: true,
844            key_order: Some(keys.into_iter().map(String::from).collect()),
845            ..Default::default()
846        })
847    }
848
849    /// Create an enabled rule with required keys for testing
850    fn create_rule_with_required_keys(keys: Vec<&str>) -> MD072FrontmatterKeySort {
851        MD072FrontmatterKeySort::from_config_struct(MD072Config {
852            enabled: true,
853            required_keys: keys.into_iter().map(String::from).collect(),
854            ..Default::default()
855        })
856    }
857
858    // ==================== Config Tests ====================
859
860    #[test]
861    fn test_enabled_via_config() {
862        let rule = create_enabled_rule();
863        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
864        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865        let result = rule.check(&ctx).unwrap();
866
867        // Enabled, should detect unsorted keys
868        assert_eq!(result.len(), 1);
869    }
870
871    // ==================== YAML Tests ====================
872
873    #[test]
874    fn test_no_frontmatter() {
875        let rule = create_enabled_rule();
876        let content = "# Heading\n\nContent.";
877        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
878        let result = rule.check(&ctx).unwrap();
879
880        assert!(result.is_empty());
881    }
882
883    #[test]
884    fn test_yaml_sorted_keys() {
885        let rule = create_enabled_rule();
886        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
887        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
888        let result = rule.check(&ctx).unwrap();
889
890        assert!(result.is_empty());
891    }
892
893    #[test]
894    fn test_yaml_unsorted_keys() {
895        let rule = create_enabled_rule();
896        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
897        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898        let result = rule.check(&ctx).unwrap();
899
900        assert_eq!(result.len(), 1);
901        assert!(result[0].message.contains("YAML"));
902        assert!(result[0].message.contains("not sorted"));
903        // Message shows first out-of-order pair: 'author' should come before 'title'
904        assert!(result[0].message.contains("'author' should come before 'title'"));
905    }
906
907    #[test]
908    fn test_yaml_case_insensitive_sort() {
909        let rule = create_enabled_rule();
910        let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
911        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
912        let result = rule.check(&ctx).unwrap();
913
914        // Author, date, Title should be considered sorted (case-insensitive)
915        assert!(result.is_empty());
916    }
917
918    #[test]
919    fn test_yaml_fix_sorts_keys() {
920        let rule = create_enabled_rule();
921        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
922        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
923        let fixed = rule.fix(&ctx).unwrap();
924
925        // Keys should be sorted
926        let author_pos = fixed.find("author:").unwrap();
927        let title_pos = fixed.find("title:").unwrap();
928        assert!(author_pos < title_pos);
929    }
930
931    #[test]
932    fn test_yaml_no_fix_with_comments() {
933        let rule = create_enabled_rule();
934        let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
935        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
936        let result = rule.check(&ctx).unwrap();
937
938        assert_eq!(result.len(), 1);
939        assert!(result[0].message.contains("auto-fix unavailable"));
940        assert!(result[0].fix.is_none());
941
942        // Fix should not modify content
943        let fixed = rule.fix(&ctx).unwrap();
944        assert_eq!(fixed, content);
945    }
946
947    #[test]
948    fn test_yaml_single_key() {
949        let rule = create_enabled_rule();
950        let content = "---\ntitle: Test\n---\n\n# Heading";
951        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952        let result = rule.check(&ctx).unwrap();
953
954        // Single key is always sorted
955        assert!(result.is_empty());
956    }
957
958    #[test]
959    fn test_yaml_nested_keys_ignored() {
960        let rule = create_enabled_rule();
961        // Only top-level keys are checked, nested keys are ignored
962        let content = "---\nauthor:\n  name: John\n  email: john@example.com\ntitle: Test\n---\n\n# Heading";
963        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964        let result = rule.check(&ctx).unwrap();
965
966        // author, title are sorted
967        assert!(result.is_empty());
968    }
969
970    #[test]
971    fn test_yaml_fix_idempotent() {
972        let rule = create_enabled_rule();
973        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
974        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975        let fixed_once = rule.fix(&ctx).unwrap();
976
977        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
978        let fixed_twice = rule.fix(&ctx2).unwrap();
979
980        assert_eq!(fixed_once, fixed_twice);
981    }
982
983    #[test]
984    fn test_yaml_fix_preserves_trailing_newline() {
985        let rule = create_enabled_rule();
986        // Content ends with a trailing newline; fix must not strip it.
987        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n";
988        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
989        let fixed = rule.fix(&ctx).unwrap();
990        assert!(
991            fixed.ends_with('\n'),
992            "trailing newline must be preserved, got {fixed:?}"
993        );
994
995        // And the fix is idempotent on trailing-newline content.
996        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
997        let fixed_twice = rule.fix(&ctx2).unwrap();
998        assert_eq!(fixed, fixed_twice);
999    }
1000
1001    #[test]
1002    fn test_yaml_fix_whole_file_frontmatter_preserves_trailing_newline() {
1003        let rule = create_enabled_rule();
1004        // Frontmatter is the entire file (no body after the closing fence).
1005        let content = "---\ntitle: Test\nauthor: John\n---\n";
1006        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1007        let fixed = rule.fix(&ctx).unwrap();
1008        assert!(
1009            fixed.ends_with('\n'),
1010            "trailing newline must be preserved, got {fixed:?}"
1011        );
1012    }
1013
1014    #[test]
1015    fn test_yaml_quoted_keys_sort_by_content() {
1016        let rule = create_enabled_rule();
1017        // A quoted key must sort by its unquoted content, not by the leading
1018        // quote char. "zebra" before apple is out of order alphabetically.
1019        let content = "---\n\"zebra\": 1\napple: 2\n---\n\n# Heading";
1020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1021        let result = rule.check(&ctx).unwrap();
1022
1023        assert_eq!(result.len(), 1, "quoted key out of order must be flagged");
1024        assert!(result[0].message.contains("'apple' should come before 'zebra'"));
1025    }
1026
1027    #[test]
1028    fn test_yaml_quoted_key_warning_span_covers_quotes() {
1029        let rule = create_enabled_rule();
1030        // "apple" is out of order (should come before banana). Its quotes are
1031        // stripped for sorting, but the diagnostic span must still cover the
1032        // raw key as written, including the quotes.
1033        let content = "---\nbanana: 1\n\"apple\": 2\n---\n";
1034        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1035        let result = rule.check(&ctx).unwrap();
1036
1037        assert_eq!(result.len(), 1);
1038        let w = &result[0];
1039        assert_eq!(w.line, 3);
1040        assert_eq!(w.column, 1);
1041        // Raw key `"apple"` is 7 chars, so end_column is 8 (not 6 for `apple`).
1042        assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
1043    }
1044
1045    #[test]
1046    fn test_yaml_complex_values() {
1047        let rule = create_enabled_rule();
1048        // Keys in sorted order: author, tags, title
1049        let content =
1050            "---\nauthor: John Doe\ntags:\n  - rust\n  - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
1051        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052        let result = rule.check(&ctx).unwrap();
1053
1054        // author, tags, title - sorted
1055        assert!(result.is_empty());
1056    }
1057
1058    // ==================== TOML Tests ====================
1059
1060    #[test]
1061    fn test_toml_sorted_keys() {
1062        let rule = create_enabled_rule();
1063        let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1064        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1065        let result = rule.check(&ctx).unwrap();
1066
1067        assert!(result.is_empty());
1068    }
1069
1070    #[test]
1071    fn test_toml_unsorted_keys() {
1072        let rule = create_enabled_rule();
1073        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
1074        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1075        let result = rule.check(&ctx).unwrap();
1076
1077        assert_eq!(result.len(), 1);
1078        assert!(result[0].message.contains("TOML"));
1079        assert!(result[0].message.contains("not sorted"));
1080    }
1081
1082    #[test]
1083    fn test_toml_fix_sorts_keys() {
1084        let rule = create_enabled_rule();
1085        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
1086        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1087        let fixed = rule.fix(&ctx).unwrap();
1088
1089        // Keys should be sorted
1090        let author_pos = fixed.find("author").unwrap();
1091        let title_pos = fixed.find("title").unwrap();
1092        assert!(author_pos < title_pos);
1093    }
1094
1095    #[test]
1096    fn test_toml_no_fix_with_comments() {
1097        let rule = create_enabled_rule();
1098        let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
1099        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1100        let result = rule.check(&ctx).unwrap();
1101
1102        assert_eq!(result.len(), 1);
1103        assert!(result[0].message.contains("auto-fix unavailable"));
1104
1105        // Fix should not modify content
1106        let fixed = rule.fix(&ctx).unwrap();
1107        assert_eq!(fixed, content);
1108    }
1109
1110    // ==================== JSON Tests ====================
1111
1112    #[test]
1113    fn test_json_sorted_keys() {
1114        let rule = create_enabled_rule();
1115        let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
1116        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1117        let result = rule.check(&ctx).unwrap();
1118
1119        assert!(result.is_empty());
1120    }
1121
1122    #[test]
1123    fn test_json_unsorted_keys() {
1124        let rule = create_enabled_rule();
1125        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1126        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1127        let result = rule.check(&ctx).unwrap();
1128
1129        assert_eq!(result.len(), 1);
1130        assert!(result[0].message.contains("JSON"));
1131        assert!(result[0].message.contains("not sorted"));
1132    }
1133
1134    #[test]
1135    fn test_json_fix_sorts_keys() {
1136        let rule = create_enabled_rule();
1137        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1138        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1139        let fixed = rule.fix(&ctx).unwrap();
1140
1141        // Keys should be sorted
1142        let author_pos = fixed.find("author").unwrap();
1143        let title_pos = fixed.find("title").unwrap();
1144        assert!(author_pos < title_pos);
1145    }
1146
1147    #[test]
1148    fn test_json_always_fixable() {
1149        let rule = create_enabled_rule();
1150        // JSON has no comments, so should always be fixable
1151        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1152        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1153        let result = rule.check(&ctx).unwrap();
1154
1155        assert_eq!(result.len(), 1);
1156        assert!(result[0].fix.is_some()); // Always fixable
1157        assert!(!result[0].message.contains("Auto-fix unavailable"));
1158    }
1159
1160    // ==================== General Tests ====================
1161
1162    #[test]
1163    fn test_empty_content() {
1164        let rule = create_enabled_rule();
1165        let content = "";
1166        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167        let result = rule.check(&ctx).unwrap();
1168
1169        assert!(result.is_empty());
1170    }
1171
1172    #[test]
1173    fn test_empty_frontmatter() {
1174        let rule = create_enabled_rule();
1175        let content = "---\n---\n\n# Heading";
1176        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1177        let result = rule.check(&ctx).unwrap();
1178
1179        assert!(result.is_empty());
1180    }
1181
1182    #[test]
1183    fn test_toml_nested_tables_ignored() {
1184        // Keys inside [extra] or [taxonomies] should NOT be checked
1185        let rule = create_enabled_rule();
1186        let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
1187        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188        let result = rule.check(&ctx).unwrap();
1189
1190        // Only top-level keys (title, sort_by) should be checked, not we_have_extra
1191        assert_eq!(result.len(), 1);
1192        // Message shows first out-of-order pair: 'sort_by' should come before 'title'
1193        assert!(result[0].message.contains("'sort_by' should come before 'title'"));
1194        assert!(!result[0].message.contains("we_have_extra"));
1195    }
1196
1197    #[test]
1198    fn test_toml_nested_taxonomies_ignored() {
1199        // Keys inside [taxonomies] should NOT be checked
1200        let rule = create_enabled_rule();
1201        let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
1202        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203        let result = rule.check(&ctx).unwrap();
1204
1205        // Only top-level keys (title, date) should be checked
1206        assert_eq!(result.len(), 1);
1207        // Message shows first out-of-order pair: 'date' should come before 'title'
1208        assert!(result[0].message.contains("'date' should come before 'title'"));
1209        assert!(!result[0].message.contains("categories"));
1210        assert!(!result[0].message.contains("tags"));
1211    }
1212
1213    // ==================== Edge Case Tests ====================
1214
1215    #[test]
1216    fn test_yaml_unicode_keys() {
1217        let rule = create_enabled_rule();
1218        // Japanese keys should sort correctly
1219        let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
1220        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1221        let result = rule.check(&ctx).unwrap();
1222
1223        // Should detect unsorted keys (あいう < タイトル < 日本語 in Unicode order)
1224        assert_eq!(result.len(), 1);
1225    }
1226
1227    #[test]
1228    fn test_yaml_keys_with_special_characters() {
1229        let rule = create_enabled_rule();
1230        // Keys with dashes and underscores
1231        let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
1232        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1233        let result = rule.check(&ctx).unwrap();
1234
1235        // my-key, my_key, mykey - should be sorted
1236        assert!(result.is_empty());
1237    }
1238
1239    #[test]
1240    fn test_yaml_keys_with_numbers() {
1241        let rule = create_enabled_rule();
1242        let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
1243        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1244        let result = rule.check(&ctx).unwrap();
1245
1246        // key1, key10, key2 - lexicographic order (1 < 10 < 2)
1247        assert!(result.is_empty());
1248    }
1249
1250    #[test]
1251    fn test_yaml_multiline_string_block_literal() {
1252        let rule = create_enabled_rule();
1253        let content =
1254            "---\ndescription: |\n  This is a\n  multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
1255        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1256        let result = rule.check(&ctx).unwrap();
1257
1258        // description, title, author - first out-of-order: 'author' should come before 'title'
1259        assert_eq!(result.len(), 1);
1260        assert!(result[0].message.contains("'author' should come before 'title'"));
1261    }
1262
1263    #[test]
1264    fn test_yaml_multiline_string_folded() {
1265        let rule = create_enabled_rule();
1266        let content = "---\ndescription: >\n  This is a\n  folded string\nauthor: John\n---\n\n# Heading";
1267        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268        let result = rule.check(&ctx).unwrap();
1269
1270        // author, description - not sorted
1271        assert_eq!(result.len(), 1);
1272    }
1273
1274    #[test]
1275    fn test_yaml_fix_preserves_multiline_values() {
1276        let rule = create_enabled_rule();
1277        let content = "---\ntitle: Test\ndescription: |\n  Line 1\n  Line 2\n---\n\n# Heading";
1278        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1279        let fixed = rule.fix(&ctx).unwrap();
1280
1281        // description should come before title
1282        let desc_pos = fixed.find("description").unwrap();
1283        let title_pos = fixed.find("title").unwrap();
1284        assert!(desc_pos < title_pos);
1285    }
1286
1287    #[test]
1288    fn test_yaml_quoted_keys() {
1289        let rule = create_enabled_rule();
1290        let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
1291        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1292        let result = rule.check(&ctx).unwrap();
1293
1294        // quoted-key should sort before unquoted
1295        assert!(result.is_empty());
1296    }
1297
1298    #[test]
1299    fn test_yaml_duplicate_keys() {
1300        // YAML allows duplicate keys (last one wins), but we should still sort
1301        let rule = create_enabled_rule();
1302        let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1303        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1304        let result = rule.check(&ctx).unwrap();
1305
1306        // Should still check sorting (title, author, title is not sorted)
1307        assert_eq!(result.len(), 1);
1308    }
1309
1310    #[test]
1311    fn test_toml_inline_table() {
1312        let rule = create_enabled_rule();
1313        let content =
1314            "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1315        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316        let result = rule.check(&ctx).unwrap();
1317
1318        // author, title - sorted
1319        assert!(result.is_empty());
1320    }
1321
1322    #[test]
1323    fn test_toml_array_of_tables() {
1324        let rule = create_enabled_rule();
1325        let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1326        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1327        let result = rule.check(&ctx).unwrap();
1328
1329        // Only top-level keys (title, date) checked - date < title, so unsorted
1330        assert_eq!(result.len(), 1);
1331        // Message shows first out-of-order pair: 'date' should come before 'title'
1332        assert!(result[0].message.contains("'date' should come before 'title'"));
1333    }
1334
1335    #[test]
1336    fn test_json_nested_objects() {
1337        let rule = create_enabled_rule();
1338        let content = "{\n\"author\": {\n  \"name\": \"John\",\n  \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1339        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1340        let result = rule.check(&ctx).unwrap();
1341
1342        // Only top-level keys (author, title) checked - sorted
1343        assert!(result.is_empty());
1344    }
1345
1346    #[test]
1347    fn test_json_arrays() {
1348        let rule = create_enabled_rule();
1349        let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1350        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351        let result = rule.check(&ctx).unwrap();
1352
1353        // author, tags - not sorted (tags comes first)
1354        assert_eq!(result.len(), 1);
1355    }
1356
1357    #[test]
1358    fn test_fix_preserves_content_after_frontmatter() {
1359        let rule = create_enabled_rule();
1360        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1361        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1362        let fixed = rule.fix(&ctx).unwrap();
1363
1364        // Verify content after frontmatter is preserved
1365        assert!(fixed.contains("# Heading"));
1366        assert!(fixed.contains("Paragraph 1."));
1367        assert!(fixed.contains("- List item"));
1368        assert!(fixed.contains("- Another item"));
1369    }
1370
1371    #[test]
1372    fn test_fix_yaml_produces_valid_yaml() {
1373        let rule = create_enabled_rule();
1374        let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1375        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1376        let fixed = rule.fix(&ctx).unwrap();
1377
1378        // The fixed output should be parseable as YAML
1379        // Extract frontmatter lines
1380        let lines: Vec<&str> = fixed.lines().collect();
1381        let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1382        let fm_content: String = lines[1..fm_end].join("\n");
1383
1384        // Should parse without error
1385        let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&fm_content);
1386        assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1387    }
1388
1389    #[test]
1390    fn test_fix_toml_produces_valid_toml() {
1391        let rule = create_enabled_rule();
1392        let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1393        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1394        let fixed = rule.fix(&ctx).unwrap();
1395
1396        // Extract frontmatter
1397        let lines: Vec<&str> = fixed.lines().collect();
1398        let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1399        let fm_content: String = lines[1..fm_end].join("\n");
1400
1401        // Should parse without error
1402        let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1403        assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1404    }
1405
1406    #[test]
1407    fn test_fix_json_produces_valid_json() {
1408        let rule = create_enabled_rule();
1409        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1410        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1411        let fixed = rule.fix(&ctx).unwrap();
1412
1413        // Extract JSON frontmatter (everything up to blank line)
1414        let json_end = fixed.find("\n\n").unwrap();
1415        let json_content = &fixed[..json_end];
1416
1417        // Should parse without error
1418        let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1419        assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1420    }
1421
1422    #[test]
1423    fn test_many_keys_performance() {
1424        let rule = create_enabled_rule();
1425        // Generate frontmatter with 100 keys
1426        let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1427        keys.reverse(); // Make them unsorted
1428        let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1429
1430        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1431        let result = rule.check(&ctx).unwrap();
1432
1433        // Should detect unsorted keys
1434        assert_eq!(result.len(), 1);
1435    }
1436
1437    #[test]
1438    fn test_yaml_empty_value() {
1439        let rule = create_enabled_rule();
1440        let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1441        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1442        let result = rule.check(&ctx).unwrap();
1443
1444        // author, title - not sorted
1445        assert_eq!(result.len(), 1);
1446    }
1447
1448    #[test]
1449    fn test_yaml_null_value() {
1450        let rule = create_enabled_rule();
1451        let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1452        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1453        let result = rule.check(&ctx).unwrap();
1454
1455        assert_eq!(result.len(), 1);
1456    }
1457
1458    #[test]
1459    fn test_yaml_boolean_values() {
1460        let rule = create_enabled_rule();
1461        let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1462        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463        let result = rule.check(&ctx).unwrap();
1464
1465        // author, draft - not sorted
1466        assert_eq!(result.len(), 1);
1467    }
1468
1469    #[test]
1470    fn test_toml_boolean_values() {
1471        let rule = create_enabled_rule();
1472        let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1473        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474        let result = rule.check(&ctx).unwrap();
1475
1476        assert_eq!(result.len(), 1);
1477    }
1478
1479    #[test]
1480    fn test_yaml_list_at_top_level() {
1481        let rule = create_enabled_rule();
1482        let content = "---\ntags:\n  - rust\n  - markdown\nauthor: John\n---\n\n# Heading";
1483        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1484        let result = rule.check(&ctx).unwrap();
1485
1486        // author, tags - not sorted (tags comes first)
1487        assert_eq!(result.len(), 1);
1488    }
1489
1490    #[test]
1491    fn test_three_keys_all_orderings() {
1492        let rule = create_enabled_rule();
1493
1494        // Test all 6 permutations of a, b, c
1495        let orderings = [
1496            ("a, b, c", "---\na: 1\nb: 2\nc: 3\n---\n\n# H", true),  // sorted
1497            ("a, c, b", "---\na: 1\nc: 3\nb: 2\n---\n\n# H", false), // unsorted
1498            ("b, a, c", "---\nb: 2\na: 1\nc: 3\n---\n\n# H", false), // unsorted
1499            ("b, c, a", "---\nb: 2\nc: 3\na: 1\n---\n\n# H", false), // unsorted
1500            ("c, a, b", "---\nc: 3\na: 1\nb: 2\n---\n\n# H", false), // unsorted
1501            ("c, b, a", "---\nc: 3\nb: 2\na: 1\n---\n\n# H", false), // unsorted
1502        ];
1503
1504        for (name, content, should_pass) in orderings {
1505            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1506            let result = rule.check(&ctx).unwrap();
1507            assert_eq!(
1508                result.is_empty(),
1509                should_pass,
1510                "Ordering {name} should {} pass",
1511                if should_pass { "" } else { "not" }
1512            );
1513        }
1514    }
1515
1516    #[test]
1517    fn test_crlf_line_endings() {
1518        let rule = create_enabled_rule();
1519        let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1520        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1521        let result = rule.check(&ctx).unwrap();
1522
1523        // Should detect unsorted keys with CRLF
1524        assert_eq!(result.len(), 1);
1525    }
1526
1527    #[test]
1528    fn test_json_escaped_quotes_in_keys() {
1529        let rule = create_enabled_rule();
1530        // This is technically invalid JSON but tests regex robustness
1531        let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1532        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1533        let result = rule.check(&ctx).unwrap();
1534
1535        // key, normal - not sorted
1536        assert_eq!(result.len(), 1);
1537    }
1538
1539    // ==================== Warning-based Fix Tests (LSP Path) ====================
1540
1541    #[test]
1542    fn test_warning_fix_yaml_sorts_keys() {
1543        let rule = create_enabled_rule();
1544        let content = "---\nbbb: 123\naaa:\n  - hello\n  - world\n---\n\n# Heading\n";
1545        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1546        let warnings = rule.check(&ctx).unwrap();
1547
1548        assert_eq!(warnings.len(), 1);
1549        assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1550
1551        let fix = warnings[0].fix.as_ref().unwrap();
1552        assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1553
1554        // Apply the fix using the warning-based fix utility (LSP path)
1555        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1556
1557        // Verify keys are sorted
1558        let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1559        let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1560        assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1561    }
1562
1563    #[test]
1564    fn test_warning_fix_preserves_yaml_list_indentation() {
1565        let rule = create_enabled_rule();
1566        let content = "---\nbbb: 123\naaa:\n  - hello\n  - world\n---\n\n# Heading\n";
1567        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1568        let warnings = rule.check(&ctx).unwrap();
1569
1570        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1571
1572        // Verify list items retain their 2-space indentation
1573        assert!(
1574            fixed.contains("  - hello"),
1575            "List indentation should be preserved: {fixed}"
1576        );
1577        assert!(
1578            fixed.contains("  - world"),
1579            "List indentation should be preserved: {fixed}"
1580        );
1581    }
1582
1583    #[test]
1584    fn test_warning_fix_preserves_nested_object_indentation() {
1585        let rule = create_enabled_rule();
1586        let content = "---\nzzzz: value\naaaa:\n  nested_key: nested_value\n  another: 123\n---\n\n# Heading\n";
1587        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1588        let warnings = rule.check(&ctx).unwrap();
1589
1590        assert_eq!(warnings.len(), 1);
1591        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1592
1593        // Verify aaaa comes before zzzz
1594        let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1595        let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1596        assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1597
1598        // Verify nested keys retain their 2-space indentation
1599        assert!(
1600            fixed.contains("  nested_key: nested_value"),
1601            "Nested object indentation should be preserved: {fixed}"
1602        );
1603        assert!(
1604            fixed.contains("  another: 123"),
1605            "Nested object indentation should be preserved: {fixed}"
1606        );
1607    }
1608
1609    #[test]
1610    fn test_warning_fix_preserves_deeply_nested_structure() {
1611        let rule = create_enabled_rule();
1612        let content = "---\nzzz: top\naaa:\n  level1:\n    level2:\n      - item1\n      - item2\n---\n\n# Content\n";
1613        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1614        let warnings = rule.check(&ctx).unwrap();
1615
1616        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1617
1618        // Verify sorting
1619        let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1620        let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1621        assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1622
1623        // Verify all indentation levels are preserved
1624        assert!(fixed.contains("  level1:"), "2-space indent should be preserved");
1625        assert!(fixed.contains("    level2:"), "4-space indent should be preserved");
1626        assert!(fixed.contains("      - item1"), "6-space indent should be preserved");
1627        assert!(fixed.contains("      - item2"), "6-space indent should be preserved");
1628    }
1629
1630    #[test]
1631    fn test_warning_fix_toml_sorts_keys() {
1632        let rule = create_enabled_rule();
1633        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1635        let warnings = rule.check(&ctx).unwrap();
1636
1637        assert_eq!(warnings.len(), 1);
1638        assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1639
1640        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1641
1642        // Verify keys are sorted
1643        let author_pos = fixed.find("author").expect("author should exist");
1644        let title_pos = fixed.find("title").expect("title should exist");
1645        assert!(author_pos < title_pos, "author should come before title");
1646    }
1647
1648    #[test]
1649    fn test_warning_fix_json_sorts_keys() {
1650        let rule = create_enabled_rule();
1651        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1652        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1653        let warnings = rule.check(&ctx).unwrap();
1654
1655        assert_eq!(warnings.len(), 1);
1656        assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1657
1658        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1659
1660        // Verify keys are sorted
1661        let author_pos = fixed.find("author").expect("author should exist");
1662        let title_pos = fixed.find("title").expect("title should exist");
1663        assert!(author_pos < title_pos, "author should come before title");
1664    }
1665
1666    #[test]
1667    fn test_warning_fix_no_fix_when_comments_present() {
1668        let rule = create_enabled_rule();
1669        let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1670        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1671        let warnings = rule.check(&ctx).unwrap();
1672
1673        assert_eq!(warnings.len(), 1);
1674        assert!(
1675            warnings[0].fix.is_none(),
1676            "Warning should NOT have a fix when comments are present"
1677        );
1678        assert!(
1679            warnings[0].message.contains("auto-fix unavailable"),
1680            "Message should indicate auto-fix is unavailable"
1681        );
1682    }
1683
1684    #[test]
1685    fn test_warning_fix_preserves_content_after_frontmatter() {
1686        let rule = create_enabled_rule();
1687        let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1688        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1689        let warnings = rule.check(&ctx).unwrap();
1690
1691        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1692
1693        // Verify content after frontmatter is preserved
1694        assert!(fixed.contains("# Heading"), "Heading should be preserved");
1695        assert!(
1696            fixed.contains("Paragraph with content."),
1697            "Paragraph should be preserved"
1698        );
1699        assert!(fixed.contains("- List item"), "List item should be preserved");
1700    }
1701
1702    #[test]
1703    fn test_warning_fix_idempotent() {
1704        let rule = create_enabled_rule();
1705        let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1706        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1707        let warnings = rule.check(&ctx).unwrap();
1708
1709        let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1710
1711        // Apply again - should produce no warnings
1712        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1713        let warnings2 = rule.check(&ctx2).unwrap();
1714
1715        assert!(
1716            warnings2.is_empty(),
1717            "After fixing, no more warnings should be produced"
1718        );
1719    }
1720
1721    #[test]
1722    fn test_warning_fix_preserves_multiline_block_literal() {
1723        let rule = create_enabled_rule();
1724        let content = "---\nzzz: simple\naaa: |\n  Line 1 of block\n  Line 2 of block\n---\n\n# Heading\n";
1725        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1726        let warnings = rule.check(&ctx).unwrap();
1727
1728        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1729
1730        // Verify block literal is preserved with indentation
1731        assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1732        assert!(
1733            fixed.contains("  Line 1 of block"),
1734            "Block literal line 1 should be preserved with indent"
1735        );
1736        assert!(
1737            fixed.contains("  Line 2 of block"),
1738            "Block literal line 2 should be preserved with indent"
1739        );
1740    }
1741
1742    #[test]
1743    fn test_warning_fix_preserves_folded_string() {
1744        let rule = create_enabled_rule();
1745        let content = "---\nzzz: simple\naaa: >\n  Folded line 1\n  Folded line 2\n---\n\n# Content\n";
1746        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1747        let warnings = rule.check(&ctx).unwrap();
1748
1749        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1750
1751        // Verify folded string is preserved
1752        assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1753        assert!(
1754            fixed.contains("  Folded line 1"),
1755            "Folded line 1 should be preserved with indent"
1756        );
1757        assert!(
1758            fixed.contains("  Folded line 2"),
1759            "Folded line 2 should be preserved with indent"
1760        );
1761    }
1762
1763    #[test]
1764    fn test_warning_fix_preserves_4_space_indentation() {
1765        let rule = create_enabled_rule();
1766        // Some projects use 4-space indentation
1767        let content = "---\nzzz: value\naaa:\n    nested: with_4_spaces\n    another: value\n---\n\n# Heading\n";
1768        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1769        let warnings = rule.check(&ctx).unwrap();
1770
1771        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1772
1773        // Verify 4-space indentation is preserved exactly
1774        assert!(
1775            fixed.contains("    nested: with_4_spaces"),
1776            "4-space indentation should be preserved: {fixed}"
1777        );
1778        assert!(
1779            fixed.contains("    another: value"),
1780            "4-space indentation should be preserved: {fixed}"
1781        );
1782    }
1783
1784    #[test]
1785    fn test_warning_fix_preserves_tab_indentation() {
1786        let rule = create_enabled_rule();
1787        // Some projects use tabs
1788        let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1789        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1790        let warnings = rule.check(&ctx).unwrap();
1791
1792        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1793
1794        // Verify tab indentation is preserved exactly
1795        assert!(
1796            fixed.contains("\tnested: with_tab"),
1797            "Tab indentation should be preserved: {fixed}"
1798        );
1799        assert!(
1800            fixed.contains("\tanother: value"),
1801            "Tab indentation should be preserved: {fixed}"
1802        );
1803    }
1804
1805    #[test]
1806    fn test_warning_fix_preserves_inline_list() {
1807        let rule = create_enabled_rule();
1808        // Inline YAML lists should be preserved
1809        let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1810        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1811        let warnings = rule.check(&ctx).unwrap();
1812
1813        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1814
1815        // Verify inline list format is preserved
1816        assert!(
1817            fixed.contains("aaa: [one, two, three]"),
1818            "Inline list should be preserved exactly: {fixed}"
1819        );
1820    }
1821
1822    #[test]
1823    fn test_warning_fix_preserves_quoted_strings() {
1824        let rule = create_enabled_rule();
1825        // Quoted strings with special chars
1826        let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1827        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1828        let warnings = rule.check(&ctx).unwrap();
1829
1830        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1831
1832        // Verify quoted strings are preserved exactly
1833        assert!(
1834            fixed.contains("aaa: \"value with: colon\""),
1835            "Double-quoted string should be preserved: {fixed}"
1836        );
1837        assert!(
1838            fixed.contains("bbb: 'single quotes'"),
1839            "Single-quoted string should be preserved: {fixed}"
1840        );
1841    }
1842
1843    // ==================== Custom Key Order Tests ====================
1844
1845    #[test]
1846    fn test_yaml_custom_key_order_sorted() {
1847        // Keys match the custom order: title, date, author
1848        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1849        let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1850        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1851        let result = rule.check(&ctx).unwrap();
1852
1853        // Keys are in the custom order, should be considered sorted
1854        assert!(result.is_empty());
1855    }
1856
1857    #[test]
1858    fn test_yaml_custom_key_order_unsorted() {
1859        // Keys NOT in the custom order: should report author before date
1860        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1861        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1862        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1863        let result = rule.check(&ctx).unwrap();
1864
1865        assert_eq!(result.len(), 1);
1866        // 'date' should come before 'author' according to custom order
1867        assert!(result[0].message.contains("'date' should come before 'author'"));
1868    }
1869
1870    #[test]
1871    fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1872        // unlisted keys should come after specified keys, sorted alphabetically
1873        let rule = create_rule_with_key_order(vec!["title"]);
1874        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1875        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1876        let result = rule.check(&ctx).unwrap();
1877
1878        // title is specified, author and date are not - they should be alphabetically after title
1879        // author < date alphabetically, so this is sorted
1880        assert!(result.is_empty());
1881    }
1882
1883    #[test]
1884    fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1885        // unlisted keys out of alphabetical order
1886        let rule = create_rule_with_key_order(vec!["title"]);
1887        let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1888        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1889        let result = rule.check(&ctx).unwrap();
1890
1891        // zebra and author are unlisted, author < zebra alphabetically
1892        assert_eq!(result.len(), 1);
1893        assert!(result[0].message.contains("'author' should come before 'zebra'"));
1894    }
1895
1896    #[test]
1897    fn test_yaml_custom_key_order_fix() {
1898        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1899        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1900        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1901        let fixed = rule.fix(&ctx).unwrap();
1902
1903        // Keys should be in custom order: title, date, author
1904        let title_pos = fixed.find("title:").unwrap();
1905        let date_pos = fixed.find("date:").unwrap();
1906        let author_pos = fixed.find("author:").unwrap();
1907        assert!(
1908            title_pos < date_pos && date_pos < author_pos,
1909            "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1910        );
1911    }
1912
1913    #[test]
1914    fn test_yaml_custom_key_order_fix_with_unlisted() {
1915        // Mix of listed and unlisted keys
1916        let rule = create_rule_with_key_order(vec!["title", "author"]);
1917        let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1918        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1919        let fixed = rule.fix(&ctx).unwrap();
1920
1921        // Order should be: title, author (specified), then aardvark, zebra (alphabetical)
1922        let title_pos = fixed.find("title:").unwrap();
1923        let author_pos = fixed.find("author:").unwrap();
1924        let aardvark_pos = fixed.find("aardvark:").unwrap();
1925        let zebra_pos = fixed.find("zebra:").unwrap();
1926
1927        assert!(
1928            title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1929            "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1930        );
1931    }
1932
1933    #[test]
1934    fn test_toml_custom_key_order_sorted() {
1935        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1936        let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1937        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1938        let result = rule.check(&ctx).unwrap();
1939
1940        assert!(result.is_empty());
1941    }
1942
1943    #[test]
1944    fn test_toml_custom_key_order_unsorted() {
1945        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1946        let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1947        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1948        let result = rule.check(&ctx).unwrap();
1949
1950        assert_eq!(result.len(), 1);
1951        assert!(result[0].message.contains("TOML"));
1952    }
1953
1954    #[test]
1955    fn test_json_custom_key_order_sorted() {
1956        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1957        let content = "{\n  \"title\": \"Test\",\n  \"date\": \"2024-01-01\",\n  \"author\": \"John\"\n}\n\n# Heading";
1958        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1959        let result = rule.check(&ctx).unwrap();
1960
1961        assert!(result.is_empty());
1962    }
1963
1964    #[test]
1965    fn test_json_custom_key_order_unsorted() {
1966        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1967        let content = "{\n  \"author\": \"John\",\n  \"title\": \"Test\",\n  \"date\": \"2024-01-01\"\n}\n\n# Heading";
1968        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1969        let result = rule.check(&ctx).unwrap();
1970
1971        assert_eq!(result.len(), 1);
1972        assert!(result[0].message.contains("JSON"));
1973    }
1974
1975    #[test]
1976    fn test_key_order_case_insensitive_match() {
1977        // Key order should match case-insensitively
1978        let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1979        let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1980        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1981        let result = rule.check(&ctx).unwrap();
1982
1983        // Keys match the custom order (case-insensitive)
1984        assert!(result.is_empty());
1985    }
1986
1987    #[test]
1988    fn test_key_order_partial_match() {
1989        // Some keys specified, some not
1990        let rule = create_rule_with_key_order(vec!["title"]);
1991        let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1992        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1993        let result = rule.check(&ctx).unwrap();
1994
1995        // Only 'title' is specified, so it comes first
1996        // 'author' and 'date' are unlisted and sorted alphabetically: author < date
1997        // But current order is date, author - WRONG
1998        // Wait, content has: title, date, author
1999        // title is specified (pos 0)
2000        // date is unlisted (pos MAX, "date")
2001        // author is unlisted (pos MAX, "author")
2002        // Since both unlisted, compare alphabetically: author < date
2003        // So author should come before date, but date comes before author in content
2004        // This IS unsorted!
2005        assert_eq!(result.len(), 1);
2006        assert!(result[0].message.contains("'author' should come before 'date'"));
2007    }
2008
2009    // ==================== Key Order Edge Cases ====================
2010
2011    #[test]
2012    fn test_key_order_empty_array_falls_back_to_alphabetical() {
2013        // Empty key_order should behave like alphabetical sorting
2014        let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2015            enabled: true,
2016            key_order: Some(vec![]),
2017            ..Default::default()
2018        });
2019        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2020        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2021        let result = rule.check(&ctx).unwrap();
2022
2023        // With empty key_order, all keys are unlisted → alphabetical
2024        // author < title, but title comes first in content → unsorted
2025        assert_eq!(result.len(), 1);
2026        assert!(result[0].message.contains("'author' should come before 'title'"));
2027    }
2028
2029    #[test]
2030    fn test_key_order_single_key() {
2031        // key_order with only one key
2032        let rule = create_rule_with_key_order(vec!["title"]);
2033        let content = "---\ntitle: Test\n---\n\n# Heading";
2034        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2035        let result = rule.check(&ctx).unwrap();
2036
2037        assert!(result.is_empty());
2038    }
2039
2040    #[test]
2041    fn test_key_order_all_keys_specified() {
2042        // All document keys are in key_order
2043        let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
2044        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
2045        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2046        let result = rule.check(&ctx).unwrap();
2047
2048        assert!(result.is_empty());
2049    }
2050
2051    #[test]
2052    fn test_key_order_no_keys_match() {
2053        // None of the document keys are in key_order
2054        let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
2055        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
2056        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2057        let result = rule.check(&ctx).unwrap();
2058
2059        // All keys are unlisted, so they sort alphabetically: author, date, title
2060        // Current order is author, date, title - which IS sorted
2061        assert!(result.is_empty());
2062    }
2063
2064    #[test]
2065    fn test_key_order_no_keys_match_unsorted() {
2066        // None of the document keys are in key_order, and they're out of alphabetical order
2067        let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
2068        let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
2069        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2070        let result = rule.check(&ctx).unwrap();
2071
2072        // All unlisted → alphabetical: author < date < title
2073        // Current: title, date, author → unsorted
2074        assert_eq!(result.len(), 1);
2075    }
2076
2077    #[test]
2078    fn test_key_order_duplicate_keys_in_config() {
2079        // Duplicate keys in key_order (should use first occurrence)
2080        let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2081            enabled: true,
2082            key_order: Some(vec![
2083                "title".to_string(),
2084                "author".to_string(),
2085                "title".to_string(), // duplicate
2086            ]),
2087            ..Default::default()
2088        });
2089        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2090        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2091        let result = rule.check(&ctx).unwrap();
2092
2093        // title (pos 0), author (pos 1) → sorted
2094        assert!(result.is_empty());
2095    }
2096
2097    #[test]
2098    fn test_key_order_with_comments_still_skips_fix() {
2099        // key_order should not affect the comment-skipping behavior
2100        let rule = create_rule_with_key_order(vec!["title", "author"]);
2101        let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
2102        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2103        let result = rule.check(&ctx).unwrap();
2104
2105        // Should detect unsorted AND indicate no auto-fix due to comments
2106        assert_eq!(result.len(), 1);
2107        assert!(result[0].message.contains("auto-fix unavailable"));
2108        assert!(result[0].fix.is_none());
2109    }
2110
2111    #[test]
2112    fn test_toml_custom_key_order_fix() {
2113        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
2114        let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
2115        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2116        let fixed = rule.fix(&ctx).unwrap();
2117
2118        // Keys should be in custom order: title, date, author
2119        let title_pos = fixed.find("title").unwrap();
2120        let date_pos = fixed.find("date").unwrap();
2121        let author_pos = fixed.find("author").unwrap();
2122        assert!(
2123            title_pos < date_pos && date_pos < author_pos,
2124            "Fixed TOML should have keys in custom order. Got:\n{fixed}"
2125        );
2126    }
2127
2128    #[test]
2129    fn test_json_custom_key_order_fix() {
2130        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
2131        let content = "{\n  \"author\": \"John\",\n  \"date\": \"2024-01-01\",\n  \"title\": \"Test\"\n}\n\n# Heading";
2132        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2133        let fixed = rule.fix(&ctx).unwrap();
2134
2135        // Keys should be in custom order: title, date, author
2136        let title_pos = fixed.find("\"title\"").unwrap();
2137        let date_pos = fixed.find("\"date\"").unwrap();
2138        let author_pos = fixed.find("\"author\"").unwrap();
2139        assert!(
2140            title_pos < date_pos && date_pos < author_pos,
2141            "Fixed JSON should have keys in custom order. Got:\n{fixed}"
2142        );
2143    }
2144
2145    #[test]
2146    fn test_key_order_unicode_keys() {
2147        // Unicode keys in key_order
2148        let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2149            enabled: true,
2150            key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
2151            ..Default::default()
2152        });
2153        let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
2154        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2155        let result = rule.check(&ctx).unwrap();
2156
2157        // Keys match the custom order
2158        assert!(result.is_empty());
2159    }
2160
2161    #[test]
2162    fn test_key_order_mixed_specified_and_unlisted_boundary() {
2163        // Test the boundary between specified and unlisted keys
2164        let rule = create_rule_with_key_order(vec!["z_last_specified"]);
2165        let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
2166        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2167        let result = rule.check(&ctx).unwrap();
2168
2169        // z_last_specified (pos 0) should come before a_first_unlisted (pos MAX)
2170        // even though 'a' < 'z' alphabetically
2171        assert!(result.is_empty());
2172    }
2173
2174    #[test]
2175    fn test_key_order_fix_preserves_values() {
2176        // Ensure fix preserves complex values when reordering with key_order
2177        let rule = create_rule_with_key_order(vec!["title", "tags"]);
2178        let content = "---\ntags:\n  - rust\n  - markdown\ntitle: Test\n---\n\n# Heading";
2179        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2180        let fixed = rule.fix(&ctx).unwrap();
2181
2182        // title should come before tags
2183        let title_pos = fixed.find("title:").unwrap();
2184        let tags_pos = fixed.find("tags:").unwrap();
2185        assert!(title_pos < tags_pos, "title should come before tags");
2186
2187        // Nested list should be preserved
2188        assert!(fixed.contains("- rust"), "List items should be preserved");
2189        assert!(fixed.contains("- markdown"), "List items should be preserved");
2190    }
2191
2192    #[test]
2193    fn test_key_order_idempotent_fix() {
2194        // Fixing twice should produce the same result
2195        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
2196        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
2197        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2198
2199        let fixed_once = rule.fix(&ctx).unwrap();
2200        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2201        let fixed_twice = rule.fix(&ctx2).unwrap();
2202
2203        assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2204    }
2205
2206    #[test]
2207    fn test_key_order_respects_later_position_over_alphabetical() {
2208        // If key_order says "z" comes before "a", that should be respected
2209        let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2210        let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
2211        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2212        let result = rule.check(&ctx).unwrap();
2213
2214        // zebra (pos 0), aardvark (pos 1) → sorted according to key_order
2215        assert!(result.is_empty());
2216    }
2217
2218    // ==================== JSON braces in string values ====================
2219
2220    #[test]
2221    fn test_json_braces_in_string_values_extracts_all_keys() {
2222        // Braces inside JSON string values should not affect depth tracking.
2223        // The key "author" (on the line after the brace-containing value) must be extracted.
2224        // Content is already sorted, so no warnings expected.
2225        let rule = create_enabled_rule();
2226        let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
2227        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2228        let result = rule.check(&ctx).unwrap();
2229
2230        // If all 4 keys are extracted, they are already sorted: author, description, tags, title
2231        assert!(
2232            result.is_empty(),
2233            "All keys should be extracted and recognized as sorted. Got: {result:?}"
2234        );
2235    }
2236
2237    #[test]
2238    fn test_json_braces_in_string_key_after_brace_value_detected() {
2239        // Specifically verify that a key appearing AFTER a line with unbalanced braces in a string is extracted
2240        let rule = create_enabled_rule();
2241        // "description" has an unbalanced `{` in its value
2242        // "author" comes on the next line and must be detected as a top-level key
2243        let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2244        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2245        let result = rule.check(&ctx).unwrap();
2246
2247        // author < description alphabetically, but description comes first => unsorted
2248        // The warning should mention 'author' should come before 'description'
2249        assert_eq!(
2250            result.len(),
2251            1,
2252            "Should detect unsorted keys after brace-containing string value"
2253        );
2254        assert!(
2255            result[0].message.contains("'author' should come before 'description'"),
2256            "Should report author before description. Got: {}",
2257            result[0].message
2258        );
2259    }
2260
2261    #[test]
2262    fn test_json_brackets_in_string_values() {
2263        // Brackets inside JSON string values should not affect depth tracking
2264        let rule = create_enabled_rule();
2265        let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2266        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2267        let result = rule.check(&ctx).unwrap();
2268
2269        // author < description, but description comes first => unsorted
2270        assert_eq!(
2271            result.len(),
2272            1,
2273            "Should detect unsorted keys despite brackets in string values"
2274        );
2275        assert!(
2276            result[0].message.contains("'author' should come before 'description'"),
2277            "Got: {}",
2278            result[0].message
2279        );
2280    }
2281
2282    #[test]
2283    fn test_json_escaped_quotes_in_values() {
2284        // Escaped quotes inside values should not break string tracking
2285        let rule = create_enabled_rule();
2286        let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2287        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2288        let result = rule.check(&ctx).unwrap();
2289
2290        // author < title, title comes first => unsorted
2291        assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
2292        assert!(
2293            result[0].message.contains("'author' should come before 'title'"),
2294            "Got: {}",
2295            result[0].message
2296        );
2297    }
2298
2299    #[test]
2300    fn test_json_multiple_braces_in_string() {
2301        // Multiple unbalanced braces in string values
2302        let rule = create_enabled_rule();
2303        let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2304        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2305        let result = rule.check(&ctx).unwrap();
2306
2307        // author < pattern, but pattern comes first => unsorted
2308        assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
2309        assert!(
2310            result[0].message.contains("'author' should come before 'pattern'"),
2311            "Got: {}",
2312            result[0].message
2313        );
2314    }
2315
2316    #[test]
2317    fn test_key_order_detects_wrong_custom_order() {
2318        // Document has aardvark before zebra, but key_order says zebra first
2319        let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2320        let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
2321        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2322        let result = rule.check(&ctx).unwrap();
2323
2324        assert_eq!(result.len(), 1);
2325        assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
2326    }
2327
2328    // ==================== Required Keys Tests ====================
2329
2330    #[test]
2331    fn test_required_keys_yaml_missing_key_warns_without_fix() {
2332        let rule = create_rule_with_required_keys(vec!["title", "date"]);
2333        let content = "---\ntitle: Test\n---\n\n# Heading";
2334        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2335        let result = rule.check(&ctx).unwrap();
2336
2337        assert_eq!(result.len(), 1);
2338        assert!(result[0].message.contains("missing required key 'date'"));
2339        assert!(result[0].message.contains("YAML"));
2340        assert!(result[0].fix.is_none(), "missing keys must not be auto-fixable");
2341        // The warning spans the opening fence on line 1.
2342        assert_eq!(result[0].line, 1);
2343        assert_eq!(result[0].column, 1);
2344        assert_eq!(result[0].end_column, 4);
2345    }
2346
2347    #[test]
2348    fn test_required_keys_all_present_no_warning() {
2349        let rule = create_rule_with_required_keys(vec!["author", "title"]);
2350        let content = "---\nauthor: John\ntitle: Test\n---\n\n# Heading";
2351        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2352        let result = rule.check(&ctx).unwrap();
2353
2354        assert!(result.is_empty());
2355    }
2356
2357    #[test]
2358    fn test_required_keys_one_warning_per_missing_key() {
2359        let rule = create_rule_with_required_keys(vec!["title", "date", "author"]);
2360        let content = "---\ntags: [a, b]\n---\n\n# Heading";
2361        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2362        let result = rule.check(&ctx).unwrap();
2363
2364        assert_eq!(result.len(), 3);
2365        let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
2366        assert!(messages.iter().any(|m| m.contains("'title'")));
2367        assert!(messages.iter().any(|m| m.contains("'date'")));
2368        assert!(messages.iter().any(|m| m.contains("'author'")));
2369    }
2370
2371    #[test]
2372    fn test_required_keys_case_insensitive_match() {
2373        // Matching is case-insensitive, consistent with key_order matching.
2374        let rule = create_rule_with_required_keys(vec!["Title"]);
2375        let content = "---\ntitle: Test\n---\n\n# Heading";
2376        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2377        let result = rule.check(&ctx).unwrap();
2378
2379        assert!(result.is_empty());
2380    }
2381
2382    #[test]
2383    fn test_required_keys_missing_and_unsorted_both_reported() {
2384        let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2385            enabled: true,
2386            required_keys: vec!["date".to_string()],
2387            ..Default::default()
2388        });
2389        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2390        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2391        let result = rule.check(&ctx).unwrap();
2392
2393        assert_eq!(result.len(), 2);
2394        assert!(result[0].message.contains("missing required key 'date'"));
2395        assert!(result[1].message.contains("'author' should come before 'title'"));
2396    }
2397
2398    #[test]
2399    fn test_required_keys_toml_missing_key() {
2400        let rule = create_rule_with_required_keys(vec!["title", "date"]);
2401        let content = "+++\ntitle = \"Test\"\n+++\n\n# Heading";
2402        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2403        let result = rule.check(&ctx).unwrap();
2404
2405        assert_eq!(result.len(), 1);
2406        assert!(
2407            result[0]
2408                .message
2409                .contains("TOML frontmatter is missing required key 'date'")
2410        );
2411        assert!(result[0].fix.is_none());
2412    }
2413
2414    #[test]
2415    fn test_required_keys_json_missing_key() {
2416        let rule = create_rule_with_required_keys(vec!["title", "date"]);
2417        let content = "{\n\"title\": \"Test\"\n}\n\n# Heading";
2418        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2419        let result = rule.check(&ctx).unwrap();
2420
2421        assert_eq!(result.len(), 1);
2422        assert!(
2423            result[0]
2424                .message
2425                .contains("JSON frontmatter is missing required key 'date'")
2426        );
2427        // JSON's opening fence is `{`, so the span is a single character.
2428        assert_eq!(result[0].end_column, 2);
2429    }
2430
2431    #[test]
2432    fn test_required_keys_no_frontmatter_no_warning() {
2433        // Whether frontmatter must exist at all is out of scope for MD072;
2434        // required keys only apply to files that have frontmatter.
2435        let rule = create_rule_with_required_keys(vec!["title"]);
2436        let content = "# Heading\n\nContent.";
2437        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2438        let result = rule.check(&ctx).unwrap();
2439
2440        assert!(result.is_empty());
2441    }
2442
2443    #[test]
2444    fn test_required_keys_empty_frontmatter_warns() {
2445        // An empty (but present) frontmatter block is missing every required key.
2446        let rule = create_rule_with_required_keys(vec!["title", "date"]);
2447        let content = "---\n---\n\n# Heading";
2448        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2449        let result = rule.check(&ctx).unwrap();
2450
2451        assert_eq!(result.len(), 2);
2452        assert!(result.iter().all(|w| w.message.contains("missing required key")));
2453    }
2454
2455    #[test]
2456    fn test_required_keys_nested_key_does_not_satisfy() {
2457        // Only top-level keys count, consistent with the sorting checks.
2458        let rule = create_rule_with_required_keys(vec!["title"]);
2459        let content = "---\nmeta:\n  title: Nested\n---\n\n# Heading";
2460        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2461        let result = rule.check(&ctx).unwrap();
2462
2463        assert_eq!(result.len(), 1);
2464        assert!(result[0].message.contains("missing required key 'title'"));
2465    }
2466
2467    #[test]
2468    fn test_required_keys_quoted_yaml_key_satisfies() {
2469        // Quoted keys are matched by their content, like the sorting checks.
2470        let rule = create_rule_with_required_keys(vec!["title"]);
2471        let content = "---\n\"title\": Test\n---\n\n# Heading";
2472        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2473        let result = rule.check(&ctx).unwrap();
2474
2475        assert!(result.is_empty());
2476    }
2477
2478    #[test]
2479    fn test_required_keys_fix_does_not_insert_keys() {
2480        // fix() must leave content unchanged when the only issue is a missing
2481        // required key: there is no meaningful value to insert.
2482        let rule = create_rule_with_required_keys(vec!["date"]);
2483        let content = "---\nauthor: John\ntitle: Test\n---\n\n# Heading";
2484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2485        let fixed = rule.fix(&ctx).unwrap();
2486
2487        assert_eq!(fixed, content);
2488    }
2489
2490    #[test]
2491    fn test_required_keys_with_key_order_subset() {
2492        // required_keys can be a subset of key_order: ordering covers many
2493        // keys, existence is enforced for a few.
2494        let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2495            enabled: true,
2496            key_order: Some(vec![
2497                "title".to_string(),
2498                "date".to_string(),
2499                "author".to_string(),
2500                "tags".to_string(),
2501            ]),
2502            required_keys: vec!["title".to_string(), "date".to_string()],
2503        });
2504
2505        // Ordered correctly but missing 'date': exactly one warning.
2506        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2507        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2508        let result = rule.check(&ctx).unwrap();
2509        assert_eq!(result.len(), 1);
2510        assert!(result[0].message.contains("missing required key 'date'"));
2511
2512        // All required keys present and ordered: clean.
2513        let content = "---\ntitle: Test\ndate: 2024-01-01\ntags: [a]\n---\n\n# Heading";
2514        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2515        let result = rule.check(&ctx).unwrap();
2516        assert!(result.is_empty());
2517    }
2518
2519    #[test]
2520    fn test_required_keys_unsorted_fix_still_applies_without_inserting() {
2521        // When keys are both unsorted and one is missing, the sort fix applies
2522        // and the missing key stays missing (and keeps warning afterwards).
2523        let rule = create_rule_with_required_keys(vec!["date"]);
2524        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2525        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2526        let fixed = rule.fix(&ctx).unwrap();
2527
2528        let author_pos = fixed.find("author:").unwrap();
2529        let title_pos = fixed.find("title:").unwrap();
2530        assert!(author_pos < title_pos, "sort fix must still apply");
2531        assert!(!fixed.contains("date"), "fix must not insert the missing key");
2532
2533        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2534        let result = rule.check(&ctx2).unwrap();
2535        assert_eq!(result.len(), 1);
2536        assert!(result[0].message.contains("missing required key 'date'"));
2537    }
2538
2539    #[test]
2540    fn test_required_keys_warning_spans_the_frontmatter_block() {
2541        // The absence belongs to the block, so the warning covers line 1
2542        // through the closing fence. The range also makes an inline disable
2543        // comment anywhere inside the frontmatter suppress the warning.
2544        let rule = create_rule_with_required_keys(vec!["date"]);
2545        let content = "---\ntitle: Test\n---\n\n# Heading";
2546        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2547        let result = rule.check(&ctx).unwrap();
2548
2549        assert_eq!(result.len(), 1);
2550        assert_eq!(result[0].line, 1);
2551        assert_eq!(result[0].column, 1);
2552        assert_eq!(result[0].end_line, 3, "span must reach the closing fence line");
2553        assert_eq!(result[0].end_column, 4);
2554    }
2555
2556    #[test]
2557    fn test_required_keys_suppressed_by_inline_disable_in_frontmatter() {
2558        // A `# <!-- rumdl-disable MD072 -->` comment inside the frontmatter
2559        // suppresses sort warnings; missing-key warnings must honor it too.
2560        // Goes through the production `lint` path, where inline-config
2561        // filtering happens.
2562        let rule = create_rule_with_required_keys(vec!["date"]);
2563        let content = "---\n# <!-- rumdl-disable MD072 -->\ntitle: Test\n---\n\n# Heading\n";
2564        let warnings = crate::lint(
2565            content,
2566            &[Box::new(rule) as Box<dyn Rule>],
2567            false,
2568            crate::config::MarkdownFlavor::Standard,
2569            None,
2570            None,
2571        )
2572        .unwrap();
2573
2574        assert!(
2575            warnings.is_empty(),
2576            "inline disable inside the frontmatter must suppress missing-key warnings, got: {warnings:?}"
2577        );
2578    }
2579
2580    #[test]
2581    fn test_required_keys_reported_through_lint_without_disable() {
2582        // Counterpart to the suppression test: the same content without the
2583        // disable comment must report through the production `lint` path.
2584        let rule = create_rule_with_required_keys(vec!["date"]);
2585        let content = "---\ntitle: Test\n---\n\n# Heading\n";
2586        let warnings = crate::lint(
2587            content,
2588            &[Box::new(rule) as Box<dyn Rule>],
2589            false,
2590            crate::config::MarkdownFlavor::Standard,
2591            None,
2592            None,
2593        )
2594        .unwrap();
2595
2596        assert_eq!(warnings.len(), 1);
2597        assert!(warnings[0].message.contains("missing required key 'date'"));
2598    }
2599
2600    #[test]
2601    fn test_required_keys_quoted_toml_key_satisfies() {
2602        // TOML basic and literal quoted keys are matched by their content.
2603        let rule = create_rule_with_required_keys(vec!["title", "date"]);
2604        let content = "+++\n\"date\" = \"2024-01-01\"\n'title' = \"Test\"\n+++\n\n# Heading";
2605        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2606        let result = rule.check(&ctx).unwrap();
2607
2608        assert!(
2609            result.is_empty(),
2610            "quoted TOML keys must satisfy required-keys, got: {result:?}"
2611        );
2612    }
2613
2614    #[test]
2615    fn test_toml_quoted_keys_sort_by_content() {
2616        // A quoted TOML key must sort by its unquoted content, not by the
2617        // leading quote char ('"' is ASCII 34 and would sort before any
2618        // unquoted key). Mirrors the YAML behavior.
2619        let rule = create_enabled_rule();
2620        let content = "+++\n\"zebra\" = 1\napple = 2\n+++\n\n# Heading";
2621        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2622        let result = rule.check(&ctx).unwrap();
2623
2624        assert_eq!(result.len(), 1, "quoted TOML key out of order must be flagged");
2625        assert!(result[0].message.contains("'apple' should come before 'zebra'"));
2626    }
2627
2628    #[test]
2629    fn test_toml_quoted_key_warning_span_covers_quotes() {
2630        // "apple" is out of order. Its quotes are stripped for sorting, but
2631        // the diagnostic span must still cover the raw key as written.
2632        let rule = create_enabled_rule();
2633        let content = "+++\nbanana = 1\n\"apple\" = 2\n+++\n";
2634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2635        let result = rule.check(&ctx).unwrap();
2636
2637        assert_eq!(result.len(), 1);
2638        let w = &result[0];
2639        assert_eq!(w.line, 3);
2640        assert_eq!(w.column, 1);
2641        // Raw key `"apple"` is 7 chars, so end_column is 8 (not 6 for `apple`).
2642        assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
2643    }
2644
2645    #[test]
2646    fn test_required_keys_json_multiple_keys_on_one_line() {
2647        // The line-based extractor captures only the first key per line (it
2648        // exists for the order check); presence must see every key, so it is
2649        // checked against a real JSON parse.
2650        let rule = create_rule_with_required_keys(vec!["title", "date"]);
2651        let content = "{\n\"title\": \"Test\", \"date\": \"2024-01-01\"\n}\n\n# Heading";
2652        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2653        let result = rule.check(&ctx).unwrap();
2654
2655        assert!(
2656            result.is_empty(),
2657            "all keys on one JSON line must satisfy required-keys, got: {result:?}"
2658        );
2659    }
2660
2661    #[test]
2662    fn test_required_keys_json_multiple_keys_on_one_line_missing_still_reported() {
2663        // Same-line keys must not mask a genuinely missing key.
2664        let rule = create_rule_with_required_keys(vec!["title", "date", "author"]);
2665        let content = "{\n\"title\": \"Test\", \"date\": \"2024-01-01\"\n}\n\n# Heading";
2666        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2667        let result = rule.check(&ctx).unwrap();
2668
2669        assert_eq!(result.len(), 1);
2670        assert!(result[0].message.contains("missing required key 'author'"));
2671    }
2672
2673    #[test]
2674    fn test_required_keys_json_invalid_falls_back_to_line_based_keys() {
2675        // Unparseable JSON falls back to the line-based extraction so a key
2676        // that is visibly present is not reported missing.
2677        let rule = create_rule_with_required_keys(vec!["title"]);
2678        let content = "{\n\"title\": unquoted-invalid\n}\n\n# Heading";
2679        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2680        let result = rule.check(&ctx).unwrap();
2681
2682        assert!(
2683            result.is_empty(),
2684            "invalid JSON must fall back to line-based key extraction, got: {result:?}"
2685        );
2686    }
2687
2688    #[test]
2689    fn test_required_keys_toml_table_header_satisfies() {
2690        // A TOML table header defines a top-level key: required `taxonomies`
2691        // is satisfied by a `[taxonomies]` section. The sort check ignores
2692        // tables, but presence must see them.
2693        let rule = create_rule_with_required_keys(vec!["title", "taxonomies"]);
2694        let content = "+++\ntitle = \"Test\"\n\n[taxonomies]\ntags = [\"a\"]\n+++\n\n# Heading";
2695        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2696        let result = rule.check(&ctx).unwrap();
2697
2698        assert!(
2699            result.is_empty(),
2700            "a TOML table header must satisfy required-keys, got: {result:?}"
2701        );
2702    }
2703
2704    #[test]
2705    fn test_required_keys_toml_array_of_tables_satisfies() {
2706        // `[[authors]]` defines the top-level key `authors`.
2707        let rule = create_rule_with_required_keys(vec!["authors"]);
2708        let content = "+++\ntitle = \"Test\"\n\n[[authors]]\nname = \"John\"\n+++\n\n# Heading";
2709        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2710        let result = rule.check(&ctx).unwrap();
2711
2712        assert!(
2713            result.is_empty(),
2714            "a TOML array-of-tables header must satisfy required-keys, got: {result:?}"
2715        );
2716    }
2717
2718    #[test]
2719    fn test_required_keys_toml_dotted_table_header_satisfies_root() {
2720        // `[params.seo]` defines the top-level key `params`.
2721        let rule = create_rule_with_required_keys(vec!["params"]);
2722        let content = "+++\ntitle = \"Test\"\n\n[params.seo]\nnoindex = true\n+++\n\n# Heading";
2723        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2724        let result = rule.check(&ctx).unwrap();
2725
2726        assert!(
2727            result.is_empty(),
2728            "a dotted TOML table header must satisfy its root key, got: {result:?}"
2729        );
2730    }
2731
2732    #[test]
2733    fn test_required_keys_toml_missing_despite_other_tables() {
2734        // Table headers must not mask a genuinely missing key.
2735        let rule = create_rule_with_required_keys(vec!["date"]);
2736        let content = "+++\ntitle = \"Test\"\n\n[taxonomies]\ntags = [\"a\"]\n+++\n\n# Heading";
2737        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2738        let result = rule.check(&ctx).unwrap();
2739
2740        assert_eq!(result.len(), 1);
2741        assert!(result[0].message.contains("missing required key 'date'"));
2742    }
2743
2744    #[test]
2745    fn test_required_keys_toml_dotted_assignment_satisfies_root() {
2746        // `params.seo = true` defines the top-level key `params`.
2747        let rule = create_rule_with_required_keys(vec!["params"]);
2748        let content = "+++\nparams.seo = true\ntitle = \"Test\"\n+++\n\n# Heading";
2749        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2750        let result = rule.check(&ctx).unwrap();
2751
2752        assert!(
2753            result.is_empty(),
2754            "a dotted TOML assignment must satisfy its root key, got: {result:?}"
2755        );
2756    }
2757
2758    #[test]
2759    fn test_required_keys_toml_quoted_dotted_key_is_atomic() {
2760        // `"a.b" = 1` defines the literal top-level key `a.b`, not `a`.
2761        let rule = create_rule_with_required_keys(vec!["a.b"]);
2762        let content = "+++\n\"a.b\" = 1\n+++\n\n# Heading";
2763        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2764        let result = rule.check(&ctx).unwrap();
2765        assert!(
2766            result.is_empty(),
2767            "quoted dotted key must match literally, got: {result:?}"
2768        );
2769
2770        let rule = create_rule_with_required_keys(vec!["a"]);
2771        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2772        let result = rule.check(&ctx).unwrap();
2773        assert_eq!(result.len(), 1, "quoted dotted key must NOT satisfy its first segment");
2774        assert!(result[0].message.contains("missing required key 'a'"));
2775    }
2776
2777    #[test]
2778    fn test_required_keys_toml_table_header_with_inline_comment() {
2779        // A valid TOML header can carry an inline comment.
2780        let rule = create_rule_with_required_keys(vec!["taxonomies"]);
2781        let content = "+++\ntitle = \"Test\"\n\n[taxonomies] # used by Hugo\ntags = [\"a\"]\n+++\n\n# Heading";
2782        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2783        let result = rule.check(&ctx).unwrap();
2784
2785        assert!(
2786            result.is_empty(),
2787            "a table header with an inline comment must satisfy required-keys, got: {result:?}"
2788        );
2789    }
2790
2791    #[test]
2792    fn test_required_keys_toml_assignment_inside_table_does_not_satisfy() {
2793        // An assignment under a table header is nested, not top-level.
2794        let rule = create_rule_with_required_keys(vec!["date"]);
2795        let content = "+++\ntitle = \"Test\"\n\n[params]\ndate = \"2024-01-01\"\n+++\n\n# Heading";
2796        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2797        let result = rule.check(&ctx).unwrap();
2798
2799        assert_eq!(result.len(), 1);
2800        assert!(result[0].message.contains("missing required key 'date'"));
2801    }
2802
2803    #[test]
2804    fn test_required_keys_yaml_quoted_key_with_colon_satisfies() {
2805        // A quoted YAML key may contain ':' (e.g. OpenGraph names); the
2806        // key/value separator is the colon outside the quotes.
2807        let rule = create_rule_with_required_keys(vec!["og:title"]);
2808        let content = "---\n\"og:title\": My post\n---\n\n# Heading";
2809        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2810        let result = rule.check(&ctx).unwrap();
2811
2812        assert!(
2813            result.is_empty(),
2814            "a quoted YAML key containing a colon must satisfy required-keys, got: {result:?}"
2815        );
2816    }
2817
2818    #[test]
2819    fn test_yaml_quoted_key_with_colon_sorts_by_full_content() {
2820        // The sort check must also see `og:title`, not a truncated `"og`.
2821        let rule = create_enabled_rule();
2822        let content = "---\n\"og:title\": My post\nalpha: 1\n---\n\n# Heading";
2823        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2824        let result = rule.check(&ctx).unwrap();
2825
2826        assert_eq!(result.len(), 1);
2827        assert!(
2828            result[0].message.contains("'alpha' should come before 'og:title'"),
2829            "sorting must use the full quoted key, got: {}",
2830            result[0].message
2831        );
2832    }
2833
2834    #[test]
2835    fn test_required_keys_toml_quoted_key_with_equals_satisfies() {
2836        // A quoted TOML key may contain '='; the assignment separator is the
2837        // '=' outside the quotes.
2838        let rule = create_rule_with_required_keys(vec!["a=b"]);
2839        let content = "+++\n\"a=b\" = 1\n+++\n\n# Heading";
2840        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2841        let result = rule.check(&ctx).unwrap();
2842
2843        assert!(
2844            result.is_empty(),
2845            "a quoted TOML key containing '=' must satisfy required-keys, got: {result:?}"
2846        );
2847    }
2848}