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