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