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
8static JSON_KEY_PATTERN: LazyLock<Regex> =
10 LazyLock::new(|| Regex::new(r#"^\s*"([^"]+)"\s*:"#).expect("Invalid JSON key regex"));
11
12#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
17pub struct MD072Config {
18 #[serde(default)]
20 pub enabled: bool,
21
22 #[serde(default, alias = "key-order")]
28 pub key_order: Option<Vec<String>>,
29}
30
31impl RuleConfig for MD072Config {
32 const RULE_NAME: &'static str = "MD072";
33}
34
35#[derive(Clone, Default)]
48pub struct MD072FrontmatterKeySort {
49 config: MD072Config,
50}
51
52impl MD072FrontmatterKeySort {
53 pub fn new() -> Self {
54 Self::default()
55 }
56
57 pub fn from_config_struct(config: MD072Config) -> Self {
59 Self { config }
60 }
61
62 fn has_comments(frontmatter_lines: &[&str]) -> bool {
64 frontmatter_lines.iter().any(|line| line.trim_start().starts_with('#'))
65 }
66
67 fn extract_yaml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
69 let mut keys = Vec::new();
70
71 for (idx, line) in frontmatter_lines.iter().enumerate() {
72 if !line.starts_with(' ')
74 && !line.starts_with('\t')
75 && let Some(colon_pos) = line.find(':')
76 {
77 let key = line[..colon_pos].trim();
78 if !key.is_empty() && !key.starts_with('#') {
79 keys.push((idx, key.to_string()));
80 }
81 }
82 }
83
84 keys
85 }
86
87 fn extract_toml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
89 let mut keys = Vec::new();
90
91 for (idx, line) in frontmatter_lines.iter().enumerate() {
92 let trimmed = line.trim();
93 if trimmed.is_empty() || trimmed.starts_with('#') {
95 continue;
96 }
97 if trimmed.starts_with('[') {
99 break;
100 }
101 if !line.starts_with(' ')
103 && !line.starts_with('\t')
104 && let Some(eq_pos) = line.find('=')
105 {
106 let key = line[..eq_pos].trim();
107 if !key.is_empty() {
108 keys.push((idx, key.to_string()));
109 }
110 }
111 }
112
113 keys
114 }
115
116 fn extract_json_keys(frontmatter_lines: &[&str]) -> Vec<String> {
118 let mut keys = Vec::new();
123 let mut depth: usize = 0;
124
125 for line in frontmatter_lines {
126 let line_start_depth = depth;
128
129 let mut in_string = false;
131 let mut prev_backslash = false;
132 for ch in line.chars() {
133 if in_string {
134 if ch == '"' && !prev_backslash {
135 in_string = false;
136 }
137 prev_backslash = ch == '\\' && !prev_backslash;
138 } else {
139 match ch {
140 '"' => in_string = true,
141 '{' | '[' => depth += 1,
142 '}' | ']' => depth = depth.saturating_sub(1),
143 _ => {}
144 }
145 prev_backslash = false;
146 }
147 }
148
149 if line_start_depth == 0
151 && let Some(captures) = JSON_KEY_PATTERN.captures(line)
152 && let Some(key_match) = captures.get(1)
153 {
154 keys.push(key_match.as_str().to_string());
155 }
156 }
157
158 keys
159 }
160
161 fn key_sort_position(key: &str, key_order: Option<&[String]>) -> (usize, String) {
165 if let Some(order) = key_order {
166 let key_lower = key.to_lowercase();
168 for (idx, ordered_key) in order.iter().enumerate() {
169 if ordered_key.to_lowercase() == key_lower {
170 return (idx, key_lower);
171 }
172 }
173 (usize::MAX, key_lower)
175 } else {
176 (0, key.to_lowercase())
178 }
179 }
180
181 fn find_first_unsorted_pair<'a>(keys: &'a [String], key_order: Option<&[String]>) -> Option<(&'a str, &'a str)> {
184 for i in 1..keys.len() {
185 let pos_curr = Self::key_sort_position(&keys[i], key_order);
186 let pos_prev = Self::key_sort_position(&keys[i - 1], key_order);
187 if pos_curr < pos_prev {
188 return Some((&keys[i], &keys[i - 1]));
189 }
190 }
191 None
192 }
193
194 fn find_first_unsorted_indexed_pair<'a>(
197 keys: &'a [(usize, String)],
198 key_order: Option<&[String]>,
199 ) -> Option<(&'a str, &'a str)> {
200 for i in 1..keys.len() {
201 let pos_curr = Self::key_sort_position(&keys[i].1, key_order);
202 let pos_prev = Self::key_sort_position(&keys[i - 1].1, key_order);
203 if pos_curr < pos_prev {
204 return Some((&keys[i].1, &keys[i - 1].1));
205 }
206 }
207 None
208 }
209
210 fn are_keys_sorted(keys: &[String], key_order: Option<&[String]>) -> bool {
212 Self::find_first_unsorted_pair(keys, key_order).is_none()
213 }
214
215 fn are_indexed_keys_sorted(keys: &[(usize, String)], key_order: Option<&[String]>) -> bool {
217 Self::find_first_unsorted_indexed_pair(keys, key_order).is_none()
218 }
219
220 fn sort_keys_by_order(keys: &mut [(String, Vec<&str>)], key_order: Option<&[String]>) {
222 keys.sort_by(|a, b| {
223 let pos_a = Self::key_sort_position(&a.0, key_order);
224 let pos_b = Self::key_sort_position(&b.0, key_order);
225 pos_a.cmp(&pos_b)
226 });
227 }
228}
229
230impl Rule for MD072FrontmatterKeySort {
231 fn name(&self) -> &'static str {
232 "MD072"
233 }
234
235 fn description(&self) -> &'static str {
236 "Frontmatter keys should be sorted alphabetically"
237 }
238
239 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
240 let content = ctx.content;
241 let mut warnings = Vec::new();
242
243 if content.is_empty() {
244 return Ok(warnings);
245 }
246
247 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
248
249 match fm_type {
250 FrontMatterType::Yaml => {
251 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
252 if frontmatter_lines.is_empty() {
253 return Ok(warnings);
254 }
255
256 let keys = Self::extract_yaml_keys(&frontmatter_lines);
257 let key_order = self.config.key_order.as_deref();
258 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_indexed_pair(&keys, key_order)
259 else {
260 return Ok(warnings);
261 };
262
263 let has_comments = Self::has_comments(&frontmatter_lines);
264
265 let fix = if has_comments {
266 None
267 } else {
268 match self.fix_yaml(content) {
270 Ok(fixed_content) if fixed_content != content => Some(Fix {
271 range: 0..content.len(),
272 replacement: fixed_content,
273 }),
274 _ => None,
275 }
276 };
277
278 let message = if has_comments {
279 format!(
280 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
281 )
282 } else {
283 format!(
284 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
285 )
286 };
287
288 warnings.push(LintWarning {
289 rule_name: Some(self.name().to_string()),
290 message,
291 line: 2, column: 1,
293 end_line: 2,
294 end_column: 1,
295 severity: Severity::Warning,
296 fix,
297 });
298 }
299 FrontMatterType::Toml => {
300 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
301 if frontmatter_lines.is_empty() {
302 return Ok(warnings);
303 }
304
305 let keys = Self::extract_toml_keys(&frontmatter_lines);
306 let key_order = self.config.key_order.as_deref();
307 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_indexed_pair(&keys, key_order)
308 else {
309 return Ok(warnings);
310 };
311
312 let has_comments = Self::has_comments(&frontmatter_lines);
313
314 let fix = if has_comments {
315 None
316 } else {
317 match self.fix_toml(content) {
319 Ok(fixed_content) if fixed_content != content => Some(Fix {
320 range: 0..content.len(),
321 replacement: fixed_content,
322 }),
323 _ => None,
324 }
325 };
326
327 let message = if has_comments {
328 format!(
329 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
330 )
331 } else {
332 format!(
333 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
334 )
335 };
336
337 warnings.push(LintWarning {
338 rule_name: Some(self.name().to_string()),
339 message,
340 line: 2, column: 1,
342 end_line: 2,
343 end_column: 1,
344 severity: Severity::Warning,
345 fix,
346 });
347 }
348 FrontMatterType::Json => {
349 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
350 if frontmatter_lines.is_empty() {
351 return Ok(warnings);
352 }
353
354 let keys = Self::extract_json_keys(&frontmatter_lines);
355 let key_order = self.config.key_order.as_deref();
356 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_pair(&keys, key_order) else {
357 return Ok(warnings);
358 };
359
360 let fix = match self.fix_json(content) {
362 Ok(fixed_content) if fixed_content != content => Some(Fix {
363 range: 0..content.len(),
364 replacement: fixed_content,
365 }),
366 _ => None,
367 };
368
369 let message = format!(
370 "JSON frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
371 );
372
373 warnings.push(LintWarning {
374 rule_name: Some(self.name().to_string()),
375 message,
376 line: 2, column: 1,
378 end_line: 2,
379 end_column: 1,
380 severity: Severity::Warning,
381 fix,
382 });
383 }
384 _ => {
385 }
387 }
388
389 Ok(warnings)
390 }
391
392 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
393 let content = ctx.content;
394
395 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
396
397 match fm_type {
398 FrontMatterType::Yaml => self.fix_yaml(content),
399 FrontMatterType::Toml => self.fix_toml(content),
400 FrontMatterType::Json => self.fix_json(content),
401 _ => Ok(content.to_string()),
402 }
403 }
404
405 fn category(&self) -> RuleCategory {
406 RuleCategory::FrontMatter
407 }
408
409 fn as_any(&self) -> &dyn std::any::Any {
410 self
411 }
412
413 fn default_config_section(&self) -> Option<(String, toml::Value)> {
414 let table = crate::rule_config_serde::config_schema_table(&MD072Config::default())?;
415 Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
416 }
417
418 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
419 where
420 Self: Sized,
421 {
422 let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
423 Box::new(Self::from_config_struct(rule_config))
424 }
425}
426
427impl MD072FrontmatterKeySort {
428 fn fix_yaml(&self, content: &str) -> Result<String, LintError> {
429 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
430 if frontmatter_lines.is_empty() {
431 return Ok(content.to_string());
432 }
433
434 if Self::has_comments(&frontmatter_lines) {
436 return Ok(content.to_string());
437 }
438
439 let keys = Self::extract_yaml_keys(&frontmatter_lines);
440 let key_order = self.config.key_order.as_deref();
441 if Self::are_indexed_keys_sorted(&keys, key_order) {
442 return Ok(content.to_string());
443 }
444
445 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
448
449 for (i, (line_idx, key)) in keys.iter().enumerate() {
450 let start = *line_idx;
451 let end = if i + 1 < keys.len() {
452 keys[i + 1].0
453 } else {
454 frontmatter_lines.len()
455 };
456
457 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
458 key_blocks.push((key.clone(), block_lines));
459 }
460
461 Self::sort_keys_by_order(&mut key_blocks, key_order);
463
464 let content_lines: Vec<&str> = content.lines().collect();
466 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
467
468 let mut result = String::new();
469 result.push_str("---\n");
470 for (_, lines) in &key_blocks {
471 for line in lines {
472 result.push_str(line);
473 result.push('\n');
474 }
475 }
476 result.push_str("---");
477
478 if fm_end < content_lines.len() {
479 result.push('\n');
480 result.push_str(&content_lines[fm_end..].join("\n"));
481 }
482
483 Ok(result)
484 }
485
486 fn fix_toml(&self, content: &str) -> Result<String, LintError> {
487 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
488 if frontmatter_lines.is_empty() {
489 return Ok(content.to_string());
490 }
491
492 if Self::has_comments(&frontmatter_lines) {
494 return Ok(content.to_string());
495 }
496
497 let keys = Self::extract_toml_keys(&frontmatter_lines);
498 let key_order = self.config.key_order.as_deref();
499 if Self::are_indexed_keys_sorted(&keys, key_order) {
500 return Ok(content.to_string());
501 }
502
503 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
506
507 for (i, (line_idx, key)) in keys.iter().enumerate() {
508 let start = *line_idx;
509 let end = if i + 1 < keys.len() {
510 keys[i + 1].0
511 } else {
512 frontmatter_lines.len()
513 };
514
515 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
516 key_blocks.push((key.clone(), block_lines));
517 }
518
519 Self::sort_keys_by_order(&mut key_blocks, key_order);
521
522 let content_lines: Vec<&str> = content.lines().collect();
524 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
525
526 let mut result = String::new();
527 result.push_str("+++\n");
528 for (_, lines) in &key_blocks {
529 for line in lines {
530 result.push_str(line);
531 result.push('\n');
532 }
533 }
534 result.push_str("+++");
535
536 if fm_end < content_lines.len() {
537 result.push('\n');
538 result.push_str(&content_lines[fm_end..].join("\n"));
539 }
540
541 Ok(result)
542 }
543
544 fn fix_json(&self, content: &str) -> Result<String, LintError> {
545 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
546 if frontmatter_lines.is_empty() {
547 return Ok(content.to_string());
548 }
549
550 let keys = Self::extract_json_keys(&frontmatter_lines);
551 let key_order = self.config.key_order.as_deref();
552
553 if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
554 return Ok(content.to_string());
555 }
556
557 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
559
560 match serde_json::from_str::<serde_json::Value>(&json_content) {
562 Ok(serde_json::Value::Object(map)) => {
563 let mut sorted_map = serde_json::Map::new();
565 let mut keys: Vec<_> = map.keys().cloned().collect();
566 keys.sort_by(|a, b| {
567 let pos_a = Self::key_sort_position(a, key_order);
568 let pos_b = Self::key_sort_position(b, key_order);
569 pos_a.cmp(&pos_b)
570 });
571
572 for key in keys {
573 if let Some(value) = map.get(&key) {
574 sorted_map.insert(key, value.clone());
575 }
576 }
577
578 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
579 Ok(sorted_json) => {
580 let lines: Vec<&str> = content.lines().collect();
581 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
582
583 let mut result = String::new();
586 result.push_str(&sorted_json);
587
588 if fm_end < lines.len() {
589 result.push('\n');
590 result.push_str(&lines[fm_end..].join("\n"));
591 }
592
593 Ok(result)
594 }
595 Err(_) => Ok(content.to_string()),
596 }
597 }
598 _ => Ok(content.to_string()),
599 }
600 }
601}
602
603#[cfg(test)]
604mod tests {
605 use super::*;
606 use crate::lint_context::LintContext;
607
608 fn create_enabled_rule() -> MD072FrontmatterKeySort {
610 MD072FrontmatterKeySort::from_config_struct(MD072Config {
611 enabled: true,
612 key_order: None,
613 })
614 }
615
616 fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
618 MD072FrontmatterKeySort::from_config_struct(MD072Config {
619 enabled: true,
620 key_order: Some(keys.into_iter().map(String::from).collect()),
621 })
622 }
623
624 #[test]
627 fn test_enabled_via_config() {
628 let rule = create_enabled_rule();
629 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
630 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631 let result = rule.check(&ctx).unwrap();
632
633 assert_eq!(result.len(), 1);
635 }
636
637 #[test]
640 fn test_no_frontmatter() {
641 let rule = create_enabled_rule();
642 let content = "# Heading\n\nContent.";
643 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
644 let result = rule.check(&ctx).unwrap();
645
646 assert!(result.is_empty());
647 }
648
649 #[test]
650 fn test_yaml_sorted_keys() {
651 let rule = create_enabled_rule();
652 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
654 let result = rule.check(&ctx).unwrap();
655
656 assert!(result.is_empty());
657 }
658
659 #[test]
660 fn test_yaml_unsorted_keys() {
661 let rule = create_enabled_rule();
662 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
664 let result = rule.check(&ctx).unwrap();
665
666 assert_eq!(result.len(), 1);
667 assert!(result[0].message.contains("YAML"));
668 assert!(result[0].message.contains("not sorted"));
669 assert!(result[0].message.contains("'author' should come before 'title'"));
671 }
672
673 #[test]
674 fn test_yaml_case_insensitive_sort() {
675 let rule = create_enabled_rule();
676 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
678 let result = rule.check(&ctx).unwrap();
679
680 assert!(result.is_empty());
682 }
683
684 #[test]
685 fn test_yaml_fix_sorts_keys() {
686 let rule = create_enabled_rule();
687 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689 let fixed = rule.fix(&ctx).unwrap();
690
691 let author_pos = fixed.find("author:").unwrap();
693 let title_pos = fixed.find("title:").unwrap();
694 assert!(author_pos < title_pos);
695 }
696
697 #[test]
698 fn test_yaml_no_fix_with_comments() {
699 let rule = create_enabled_rule();
700 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702 let result = rule.check(&ctx).unwrap();
703
704 assert_eq!(result.len(), 1);
705 assert!(result[0].message.contains("auto-fix unavailable"));
706 assert!(result[0].fix.is_none());
707
708 let fixed = rule.fix(&ctx).unwrap();
710 assert_eq!(fixed, content);
711 }
712
713 #[test]
714 fn test_yaml_single_key() {
715 let rule = create_enabled_rule();
716 let content = "---\ntitle: Test\n---\n\n# Heading";
717 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
718 let result = rule.check(&ctx).unwrap();
719
720 assert!(result.is_empty());
722 }
723
724 #[test]
725 fn test_yaml_nested_keys_ignored() {
726 let rule = create_enabled_rule();
727 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730 let result = rule.check(&ctx).unwrap();
731
732 assert!(result.is_empty());
734 }
735
736 #[test]
737 fn test_yaml_fix_idempotent() {
738 let rule = create_enabled_rule();
739 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
740 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741 let fixed_once = rule.fix(&ctx).unwrap();
742
743 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
744 let fixed_twice = rule.fix(&ctx2).unwrap();
745
746 assert_eq!(fixed_once, fixed_twice);
747 }
748
749 #[test]
750 fn test_yaml_complex_values() {
751 let rule = create_enabled_rule();
752 let content =
754 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let result = rule.check(&ctx).unwrap();
757
758 assert!(result.is_empty());
760 }
761
762 #[test]
765 fn test_toml_sorted_keys() {
766 let rule = create_enabled_rule();
767 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
768 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769 let result = rule.check(&ctx).unwrap();
770
771 assert!(result.is_empty());
772 }
773
774 #[test]
775 fn test_toml_unsorted_keys() {
776 let rule = create_enabled_rule();
777 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
778 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
779 let result = rule.check(&ctx).unwrap();
780
781 assert_eq!(result.len(), 1);
782 assert!(result[0].message.contains("TOML"));
783 assert!(result[0].message.contains("not sorted"));
784 }
785
786 #[test]
787 fn test_toml_fix_sorts_keys() {
788 let rule = create_enabled_rule();
789 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
791 let fixed = rule.fix(&ctx).unwrap();
792
793 let author_pos = fixed.find("author").unwrap();
795 let title_pos = fixed.find("title").unwrap();
796 assert!(author_pos < title_pos);
797 }
798
799 #[test]
800 fn test_toml_no_fix_with_comments() {
801 let rule = create_enabled_rule();
802 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
803 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
804 let result = rule.check(&ctx).unwrap();
805
806 assert_eq!(result.len(), 1);
807 assert!(result[0].message.contains("auto-fix unavailable"));
808
809 let fixed = rule.fix(&ctx).unwrap();
811 assert_eq!(fixed, content);
812 }
813
814 #[test]
817 fn test_json_sorted_keys() {
818 let rule = create_enabled_rule();
819 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
820 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
821 let result = rule.check(&ctx).unwrap();
822
823 assert!(result.is_empty());
824 }
825
826 #[test]
827 fn test_json_unsorted_keys() {
828 let rule = create_enabled_rule();
829 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
830 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
831 let result = rule.check(&ctx).unwrap();
832
833 assert_eq!(result.len(), 1);
834 assert!(result[0].message.contains("JSON"));
835 assert!(result[0].message.contains("not sorted"));
836 }
837
838 #[test]
839 fn test_json_fix_sorts_keys() {
840 let rule = create_enabled_rule();
841 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843 let fixed = rule.fix(&ctx).unwrap();
844
845 let author_pos = fixed.find("author").unwrap();
847 let title_pos = fixed.find("title").unwrap();
848 assert!(author_pos < title_pos);
849 }
850
851 #[test]
852 fn test_json_always_fixable() {
853 let rule = create_enabled_rule();
854 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857 let result = rule.check(&ctx).unwrap();
858
859 assert_eq!(result.len(), 1);
860 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
862 }
863
864 #[test]
867 fn test_empty_content() {
868 let rule = create_enabled_rule();
869 let content = "";
870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
871 let result = rule.check(&ctx).unwrap();
872
873 assert!(result.is_empty());
874 }
875
876 #[test]
877 fn test_empty_frontmatter() {
878 let rule = create_enabled_rule();
879 let content = "---\n---\n\n# Heading";
880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
881 let result = rule.check(&ctx).unwrap();
882
883 assert!(result.is_empty());
884 }
885
886 #[test]
887 fn test_toml_nested_tables_ignored() {
888 let rule = create_enabled_rule();
890 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
891 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892 let result = rule.check(&ctx).unwrap();
893
894 assert_eq!(result.len(), 1);
896 assert!(result[0].message.contains("'sort_by' should come before 'title'"));
898 assert!(!result[0].message.contains("we_have_extra"));
899 }
900
901 #[test]
902 fn test_toml_nested_taxonomies_ignored() {
903 let rule = create_enabled_rule();
905 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907 let result = rule.check(&ctx).unwrap();
908
909 assert_eq!(result.len(), 1);
911 assert!(result[0].message.contains("'date' should come before 'title'"));
913 assert!(!result[0].message.contains("categories"));
914 assert!(!result[0].message.contains("tags"));
915 }
916
917 #[test]
920 fn test_yaml_unicode_keys() {
921 let rule = create_enabled_rule();
922 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
925 let result = rule.check(&ctx).unwrap();
926
927 assert_eq!(result.len(), 1);
929 }
930
931 #[test]
932 fn test_yaml_keys_with_special_characters() {
933 let rule = create_enabled_rule();
934 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937 let result = rule.check(&ctx).unwrap();
938
939 assert!(result.is_empty());
941 }
942
943 #[test]
944 fn test_yaml_keys_with_numbers() {
945 let rule = create_enabled_rule();
946 let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
948 let result = rule.check(&ctx).unwrap();
949
950 assert!(result.is_empty());
952 }
953
954 #[test]
955 fn test_yaml_multiline_string_block_literal() {
956 let rule = create_enabled_rule();
957 let content =
958 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960 let result = rule.check(&ctx).unwrap();
961
962 assert_eq!(result.len(), 1);
964 assert!(result[0].message.contains("'author' should come before 'title'"));
965 }
966
967 #[test]
968 fn test_yaml_multiline_string_folded() {
969 let rule = create_enabled_rule();
970 let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\n---\n\n# Heading";
971 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972 let result = rule.check(&ctx).unwrap();
973
974 assert_eq!(result.len(), 1);
976 }
977
978 #[test]
979 fn test_yaml_fix_preserves_multiline_values() {
980 let rule = create_enabled_rule();
981 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
982 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
983 let fixed = rule.fix(&ctx).unwrap();
984
985 let desc_pos = fixed.find("description").unwrap();
987 let title_pos = fixed.find("title").unwrap();
988 assert!(desc_pos < title_pos);
989 }
990
991 #[test]
992 fn test_yaml_quoted_keys() {
993 let rule = create_enabled_rule();
994 let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
995 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.check(&ctx).unwrap();
997
998 assert!(result.is_empty());
1000 }
1001
1002 #[test]
1003 fn test_yaml_duplicate_keys() {
1004 let rule = create_enabled_rule();
1006 let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1007 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008 let result = rule.check(&ctx).unwrap();
1009
1010 assert_eq!(result.len(), 1);
1012 }
1013
1014 #[test]
1015 fn test_toml_inline_table() {
1016 let rule = create_enabled_rule();
1017 let content =
1018 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1019 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020 let result = rule.check(&ctx).unwrap();
1021
1022 assert!(result.is_empty());
1024 }
1025
1026 #[test]
1027 fn test_toml_array_of_tables() {
1028 let rule = create_enabled_rule();
1029 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1030 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1031 let result = rule.check(&ctx).unwrap();
1032
1033 assert_eq!(result.len(), 1);
1035 assert!(result[0].message.contains("'date' should come before 'title'"));
1037 }
1038
1039 #[test]
1040 fn test_json_nested_objects() {
1041 let rule = create_enabled_rule();
1042 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1043 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1044 let result = rule.check(&ctx).unwrap();
1045
1046 assert!(result.is_empty());
1048 }
1049
1050 #[test]
1051 fn test_json_arrays() {
1052 let rule = create_enabled_rule();
1053 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1054 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1055 let result = rule.check(&ctx).unwrap();
1056
1057 assert_eq!(result.len(), 1);
1059 }
1060
1061 #[test]
1062 fn test_fix_preserves_content_after_frontmatter() {
1063 let rule = create_enabled_rule();
1064 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1065 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1066 let fixed = rule.fix(&ctx).unwrap();
1067
1068 assert!(fixed.contains("# Heading"));
1070 assert!(fixed.contains("Paragraph 1."));
1071 assert!(fixed.contains("- List item"));
1072 assert!(fixed.contains("- Another item"));
1073 }
1074
1075 #[test]
1076 fn test_fix_yaml_produces_valid_yaml() {
1077 let rule = create_enabled_rule();
1078 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1079 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1080 let fixed = rule.fix(&ctx).unwrap();
1081
1082 let lines: Vec<&str> = fixed.lines().collect();
1085 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1086 let fm_content: String = lines[1..fm_end].join("\n");
1087
1088 let parsed: Result<serde_yml::Value, _> = serde_yml::from_str(&fm_content);
1090 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1091 }
1092
1093 #[test]
1094 fn test_fix_toml_produces_valid_toml() {
1095 let rule = create_enabled_rule();
1096 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1097 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1098 let fixed = rule.fix(&ctx).unwrap();
1099
1100 let lines: Vec<&str> = fixed.lines().collect();
1102 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1103 let fm_content: String = lines[1..fm_end].join("\n");
1104
1105 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1107 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1108 }
1109
1110 #[test]
1111 fn test_fix_json_produces_valid_json() {
1112 let rule = create_enabled_rule();
1113 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1114 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1115 let fixed = rule.fix(&ctx).unwrap();
1116
1117 let json_end = fixed.find("\n\n").unwrap();
1119 let json_content = &fixed[..json_end];
1120
1121 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1123 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1124 }
1125
1126 #[test]
1127 fn test_many_keys_performance() {
1128 let rule = create_enabled_rule();
1129 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1131 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1133
1134 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1135 let result = rule.check(&ctx).unwrap();
1136
1137 assert_eq!(result.len(), 1);
1139 }
1140
1141 #[test]
1142 fn test_yaml_empty_value() {
1143 let rule = create_enabled_rule();
1144 let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1145 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1146 let result = rule.check(&ctx).unwrap();
1147
1148 assert_eq!(result.len(), 1);
1150 }
1151
1152 #[test]
1153 fn test_yaml_null_value() {
1154 let rule = create_enabled_rule();
1155 let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1156 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1157 let result = rule.check(&ctx).unwrap();
1158
1159 assert_eq!(result.len(), 1);
1160 }
1161
1162 #[test]
1163 fn test_yaml_boolean_values() {
1164 let rule = create_enabled_rule();
1165 let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1166 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167 let result = rule.check(&ctx).unwrap();
1168
1169 assert_eq!(result.len(), 1);
1171 }
1172
1173 #[test]
1174 fn test_toml_boolean_values() {
1175 let rule = create_enabled_rule();
1176 let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1177 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1178 let result = rule.check(&ctx).unwrap();
1179
1180 assert_eq!(result.len(), 1);
1181 }
1182
1183 #[test]
1184 fn test_yaml_list_at_top_level() {
1185 let rule = create_enabled_rule();
1186 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188 let result = rule.check(&ctx).unwrap();
1189
1190 assert_eq!(result.len(), 1);
1192 }
1193
1194 #[test]
1195 fn test_three_keys_all_orderings() {
1196 let rule = create_enabled_rule();
1197
1198 let orderings = [
1200 ("a, b, c", "---\na: 1\nb: 2\nc: 3\n---\n\n# H", true), ("a, c, b", "---\na: 1\nc: 3\nb: 2\n---\n\n# H", false), ("b, a, c", "---\nb: 2\na: 1\nc: 3\n---\n\n# H", false), ("b, c, a", "---\nb: 2\nc: 3\na: 1\n---\n\n# H", false), ("c, a, b", "---\nc: 3\na: 1\nb: 2\n---\n\n# H", false), ("c, b, a", "---\nc: 3\nb: 2\na: 1\n---\n\n# H", false), ];
1207
1208 for (name, content, should_pass) in orderings {
1209 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 let result = rule.check(&ctx).unwrap();
1211 assert_eq!(
1212 result.is_empty(),
1213 should_pass,
1214 "Ordering {name} should {} pass",
1215 if should_pass { "" } else { "not" }
1216 );
1217 }
1218 }
1219
1220 #[test]
1221 fn test_crlf_line_endings() {
1222 let rule = create_enabled_rule();
1223 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226
1227 assert_eq!(result.len(), 1);
1229 }
1230
1231 #[test]
1232 fn test_json_escaped_quotes_in_keys() {
1233 let rule = create_enabled_rule();
1234 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1236 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237 let result = rule.check(&ctx).unwrap();
1238
1239 assert_eq!(result.len(), 1);
1241 }
1242
1243 #[test]
1246 fn test_warning_fix_yaml_sorts_keys() {
1247 let rule = create_enabled_rule();
1248 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250 let warnings = rule.check(&ctx).unwrap();
1251
1252 assert_eq!(warnings.len(), 1);
1253 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1254
1255 let fix = warnings[0].fix.as_ref().unwrap();
1256 assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1257
1258 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1260
1261 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1263 let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1264 assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1265 }
1266
1267 #[test]
1268 fn test_warning_fix_preserves_yaml_list_indentation() {
1269 let rule = create_enabled_rule();
1270 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1272 let warnings = rule.check(&ctx).unwrap();
1273
1274 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1275
1276 assert!(
1278 fixed.contains(" - hello"),
1279 "List indentation should be preserved: {fixed}"
1280 );
1281 assert!(
1282 fixed.contains(" - world"),
1283 "List indentation should be preserved: {fixed}"
1284 );
1285 }
1286
1287 #[test]
1288 fn test_warning_fix_preserves_nested_object_indentation() {
1289 let rule = create_enabled_rule();
1290 let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
1291 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1292 let warnings = rule.check(&ctx).unwrap();
1293
1294 assert_eq!(warnings.len(), 1);
1295 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1296
1297 let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1299 let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1300 assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1301
1302 assert!(
1304 fixed.contains(" nested_key: nested_value"),
1305 "Nested object indentation should be preserved: {fixed}"
1306 );
1307 assert!(
1308 fixed.contains(" another: 123"),
1309 "Nested object indentation should be preserved: {fixed}"
1310 );
1311 }
1312
1313 #[test]
1314 fn test_warning_fix_preserves_deeply_nested_structure() {
1315 let rule = create_enabled_rule();
1316 let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
1317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1318 let warnings = rule.check(&ctx).unwrap();
1319
1320 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1321
1322 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1324 let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1325 assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1326
1327 assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
1329 assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
1330 assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
1331 assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
1332 }
1333
1334 #[test]
1335 fn test_warning_fix_toml_sorts_keys() {
1336 let rule = create_enabled_rule();
1337 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1338 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1339 let warnings = rule.check(&ctx).unwrap();
1340
1341 assert_eq!(warnings.len(), 1);
1342 assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1343
1344 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1345
1346 let author_pos = fixed.find("author").expect("author should exist");
1348 let title_pos = fixed.find("title").expect("title should exist");
1349 assert!(author_pos < title_pos, "author should come before title");
1350 }
1351
1352 #[test]
1353 fn test_warning_fix_json_sorts_keys() {
1354 let rule = create_enabled_rule();
1355 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 let warnings = rule.check(&ctx).unwrap();
1358
1359 assert_eq!(warnings.len(), 1);
1360 assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1361
1362 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1363
1364 let author_pos = fixed.find("author").expect("author should exist");
1366 let title_pos = fixed.find("title").expect("title should exist");
1367 assert!(author_pos < title_pos, "author should come before title");
1368 }
1369
1370 #[test]
1371 fn test_warning_fix_no_fix_when_comments_present() {
1372 let rule = create_enabled_rule();
1373 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1374 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1375 let warnings = rule.check(&ctx).unwrap();
1376
1377 assert_eq!(warnings.len(), 1);
1378 assert!(
1379 warnings[0].fix.is_none(),
1380 "Warning should NOT have a fix when comments are present"
1381 );
1382 assert!(
1383 warnings[0].message.contains("auto-fix unavailable"),
1384 "Message should indicate auto-fix is unavailable"
1385 );
1386 }
1387
1388 #[test]
1389 fn test_warning_fix_preserves_content_after_frontmatter() {
1390 let rule = create_enabled_rule();
1391 let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1393 let warnings = rule.check(&ctx).unwrap();
1394
1395 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1396
1397 assert!(fixed.contains("# Heading"), "Heading should be preserved");
1399 assert!(
1400 fixed.contains("Paragraph with content."),
1401 "Paragraph should be preserved"
1402 );
1403 assert!(fixed.contains("- List item"), "List item should be preserved");
1404 }
1405
1406 #[test]
1407 fn test_warning_fix_idempotent() {
1408 let rule = create_enabled_rule();
1409 let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1411 let warnings = rule.check(&ctx).unwrap();
1412
1413 let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1414
1415 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1417 let warnings2 = rule.check(&ctx2).unwrap();
1418
1419 assert!(
1420 warnings2.is_empty(),
1421 "After fixing, no more warnings should be produced"
1422 );
1423 }
1424
1425 #[test]
1426 fn test_warning_fix_preserves_multiline_block_literal() {
1427 let rule = create_enabled_rule();
1428 let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
1429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1430 let warnings = rule.check(&ctx).unwrap();
1431
1432 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1433
1434 assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1436 assert!(
1437 fixed.contains(" Line 1 of block"),
1438 "Block literal line 1 should be preserved with indent"
1439 );
1440 assert!(
1441 fixed.contains(" Line 2 of block"),
1442 "Block literal line 2 should be preserved with indent"
1443 );
1444 }
1445
1446 #[test]
1447 fn test_warning_fix_preserves_folded_string() {
1448 let rule = create_enabled_rule();
1449 let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\n";
1450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1451 let warnings = rule.check(&ctx).unwrap();
1452
1453 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1454
1455 assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1457 assert!(
1458 fixed.contains(" Folded line 1"),
1459 "Folded line 1 should be preserved with indent"
1460 );
1461 assert!(
1462 fixed.contains(" Folded line 2"),
1463 "Folded line 2 should be preserved with indent"
1464 );
1465 }
1466
1467 #[test]
1468 fn test_warning_fix_preserves_4_space_indentation() {
1469 let rule = create_enabled_rule();
1470 let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
1472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1473 let warnings = rule.check(&ctx).unwrap();
1474
1475 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1476
1477 assert!(
1479 fixed.contains(" nested: with_4_spaces"),
1480 "4-space indentation should be preserved: {fixed}"
1481 );
1482 assert!(
1483 fixed.contains(" another: value"),
1484 "4-space indentation should be preserved: {fixed}"
1485 );
1486 }
1487
1488 #[test]
1489 fn test_warning_fix_preserves_tab_indentation() {
1490 let rule = create_enabled_rule();
1491 let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1493 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1494 let warnings = rule.check(&ctx).unwrap();
1495
1496 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1497
1498 assert!(
1500 fixed.contains("\tnested: with_tab"),
1501 "Tab indentation should be preserved: {fixed}"
1502 );
1503 assert!(
1504 fixed.contains("\tanother: value"),
1505 "Tab indentation should be preserved: {fixed}"
1506 );
1507 }
1508
1509 #[test]
1510 fn test_warning_fix_preserves_inline_list() {
1511 let rule = create_enabled_rule();
1512 let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1515 let warnings = rule.check(&ctx).unwrap();
1516
1517 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1518
1519 assert!(
1521 fixed.contains("aaa: [one, two, three]"),
1522 "Inline list should be preserved exactly: {fixed}"
1523 );
1524 }
1525
1526 #[test]
1527 fn test_warning_fix_preserves_quoted_strings() {
1528 let rule = create_enabled_rule();
1529 let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1532 let warnings = rule.check(&ctx).unwrap();
1533
1534 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1535
1536 assert!(
1538 fixed.contains("aaa: \"value with: colon\""),
1539 "Double-quoted string should be preserved: {fixed}"
1540 );
1541 assert!(
1542 fixed.contains("bbb: 'single quotes'"),
1543 "Single-quoted string should be preserved: {fixed}"
1544 );
1545 }
1546
1547 #[test]
1550 fn test_yaml_custom_key_order_sorted() {
1551 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1553 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1555 let result = rule.check(&ctx).unwrap();
1556
1557 assert!(result.is_empty());
1559 }
1560
1561 #[test]
1562 fn test_yaml_custom_key_order_unsorted() {
1563 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1565 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1566 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1567 let result = rule.check(&ctx).unwrap();
1568
1569 assert_eq!(result.len(), 1);
1570 assert!(result[0].message.contains("'date' should come before 'author'"));
1572 }
1573
1574 #[test]
1575 fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1576 let rule = create_rule_with_key_order(vec!["title"]);
1578 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1579 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1580 let result = rule.check(&ctx).unwrap();
1581
1582 assert!(result.is_empty());
1585 }
1586
1587 #[test]
1588 fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1589 let rule = create_rule_with_key_order(vec!["title"]);
1591 let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1592 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1593 let result = rule.check(&ctx).unwrap();
1594
1595 assert_eq!(result.len(), 1);
1597 assert!(result[0].message.contains("'author' should come before 'zebra'"));
1598 }
1599
1600 #[test]
1601 fn test_yaml_custom_key_order_fix() {
1602 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1603 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1605 let fixed = rule.fix(&ctx).unwrap();
1606
1607 let title_pos = fixed.find("title:").unwrap();
1609 let date_pos = fixed.find("date:").unwrap();
1610 let author_pos = fixed.find("author:").unwrap();
1611 assert!(
1612 title_pos < date_pos && date_pos < author_pos,
1613 "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1614 );
1615 }
1616
1617 #[test]
1618 fn test_yaml_custom_key_order_fix_with_unlisted() {
1619 let rule = create_rule_with_key_order(vec!["title", "author"]);
1621 let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1622 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1623 let fixed = rule.fix(&ctx).unwrap();
1624
1625 let title_pos = fixed.find("title:").unwrap();
1627 let author_pos = fixed.find("author:").unwrap();
1628 let aardvark_pos = fixed.find("aardvark:").unwrap();
1629 let zebra_pos = fixed.find("zebra:").unwrap();
1630
1631 assert!(
1632 title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1633 "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1634 );
1635 }
1636
1637 #[test]
1638 fn test_toml_custom_key_order_sorted() {
1639 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1640 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1641 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1642 let result = rule.check(&ctx).unwrap();
1643
1644 assert!(result.is_empty());
1645 }
1646
1647 #[test]
1648 fn test_toml_custom_key_order_unsorted() {
1649 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1650 let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1651 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1652 let result = rule.check(&ctx).unwrap();
1653
1654 assert_eq!(result.len(), 1);
1655 assert!(result[0].message.contains("TOML"));
1656 }
1657
1658 #[test]
1659 fn test_json_custom_key_order_sorted() {
1660 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1661 let content = "{\n \"title\": \"Test\",\n \"date\": \"2024-01-01\",\n \"author\": \"John\"\n}\n\n# Heading";
1662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1663 let result = rule.check(&ctx).unwrap();
1664
1665 assert!(result.is_empty());
1666 }
1667
1668 #[test]
1669 fn test_json_custom_key_order_unsorted() {
1670 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1671 let content = "{\n \"author\": \"John\",\n \"title\": \"Test\",\n \"date\": \"2024-01-01\"\n}\n\n# Heading";
1672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1673 let result = rule.check(&ctx).unwrap();
1674
1675 assert_eq!(result.len(), 1);
1676 assert!(result[0].message.contains("JSON"));
1677 }
1678
1679 #[test]
1680 fn test_key_order_case_insensitive_match() {
1681 let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1683 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1684 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1685 let result = rule.check(&ctx).unwrap();
1686
1687 assert!(result.is_empty());
1689 }
1690
1691 #[test]
1692 fn test_key_order_partial_match() {
1693 let rule = create_rule_with_key_order(vec!["title"]);
1695 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1696 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1697 let result = rule.check(&ctx).unwrap();
1698
1699 assert_eq!(result.len(), 1);
1710 assert!(result[0].message.contains("'author' should come before 'date'"));
1711 }
1712
1713 #[test]
1716 fn test_key_order_empty_array_falls_back_to_alphabetical() {
1717 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1719 enabled: true,
1720 key_order: Some(vec![]),
1721 });
1722 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1723 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1724 let result = rule.check(&ctx).unwrap();
1725
1726 assert_eq!(result.len(), 1);
1729 assert!(result[0].message.contains("'author' should come before 'title'"));
1730 }
1731
1732 #[test]
1733 fn test_key_order_single_key() {
1734 let rule = create_rule_with_key_order(vec!["title"]);
1736 let content = "---\ntitle: Test\n---\n\n# Heading";
1737 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1738 let result = rule.check(&ctx).unwrap();
1739
1740 assert!(result.is_empty());
1741 }
1742
1743 #[test]
1744 fn test_key_order_all_keys_specified() {
1745 let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
1747 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1749 let result = rule.check(&ctx).unwrap();
1750
1751 assert!(result.is_empty());
1752 }
1753
1754 #[test]
1755 fn test_key_order_no_keys_match() {
1756 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1758 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1759 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1760 let result = rule.check(&ctx).unwrap();
1761
1762 assert!(result.is_empty());
1765 }
1766
1767 #[test]
1768 fn test_key_order_no_keys_match_unsorted() {
1769 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1771 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1772 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1773 let result = rule.check(&ctx).unwrap();
1774
1775 assert_eq!(result.len(), 1);
1778 }
1779
1780 #[test]
1781 fn test_key_order_duplicate_keys_in_config() {
1782 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1784 enabled: true,
1785 key_order: Some(vec![
1786 "title".to_string(),
1787 "author".to_string(),
1788 "title".to_string(), ]),
1790 });
1791 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1792 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1793 let result = rule.check(&ctx).unwrap();
1794
1795 assert!(result.is_empty());
1797 }
1798
1799 #[test]
1800 fn test_key_order_with_comments_still_skips_fix() {
1801 let rule = create_rule_with_key_order(vec!["title", "author"]);
1803 let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
1804 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1805 let result = rule.check(&ctx).unwrap();
1806
1807 assert_eq!(result.len(), 1);
1809 assert!(result[0].message.contains("auto-fix unavailable"));
1810 assert!(result[0].fix.is_none());
1811 }
1812
1813 #[test]
1814 fn test_toml_custom_key_order_fix() {
1815 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1816 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1818 let fixed = rule.fix(&ctx).unwrap();
1819
1820 let title_pos = fixed.find("title").unwrap();
1822 let date_pos = fixed.find("date").unwrap();
1823 let author_pos = fixed.find("author").unwrap();
1824 assert!(
1825 title_pos < date_pos && date_pos < author_pos,
1826 "Fixed TOML should have keys in custom order. Got:\n{fixed}"
1827 );
1828 }
1829
1830 #[test]
1831 fn test_json_custom_key_order_fix() {
1832 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1833 let content = "{\n \"author\": \"John\",\n \"date\": \"2024-01-01\",\n \"title\": \"Test\"\n}\n\n# Heading";
1834 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1835 let fixed = rule.fix(&ctx).unwrap();
1836
1837 let title_pos = fixed.find("\"title\"").unwrap();
1839 let date_pos = fixed.find("\"date\"").unwrap();
1840 let author_pos = fixed.find("\"author\"").unwrap();
1841 assert!(
1842 title_pos < date_pos && date_pos < author_pos,
1843 "Fixed JSON should have keys in custom order. Got:\n{fixed}"
1844 );
1845 }
1846
1847 #[test]
1848 fn test_key_order_unicode_keys() {
1849 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1851 enabled: true,
1852 key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
1853 });
1854 let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
1855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1856 let result = rule.check(&ctx).unwrap();
1857
1858 assert!(result.is_empty());
1860 }
1861
1862 #[test]
1863 fn test_key_order_mixed_specified_and_unlisted_boundary() {
1864 let rule = create_rule_with_key_order(vec!["z_last_specified"]);
1866 let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
1867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1868 let result = rule.check(&ctx).unwrap();
1869
1870 assert!(result.is_empty());
1873 }
1874
1875 #[test]
1876 fn test_key_order_fix_preserves_values() {
1877 let rule = create_rule_with_key_order(vec!["title", "tags"]);
1879 let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n\n# Heading";
1880 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1881 let fixed = rule.fix(&ctx).unwrap();
1882
1883 let title_pos = fixed.find("title:").unwrap();
1885 let tags_pos = fixed.find("tags:").unwrap();
1886 assert!(title_pos < tags_pos, "title should come before tags");
1887
1888 assert!(fixed.contains("- rust"), "List items should be preserved");
1890 assert!(fixed.contains("- markdown"), "List items should be preserved");
1891 }
1892
1893 #[test]
1894 fn test_key_order_idempotent_fix() {
1895 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1897 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1898 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1899
1900 let fixed_once = rule.fix(&ctx).unwrap();
1901 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1902 let fixed_twice = rule.fix(&ctx2).unwrap();
1903
1904 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1905 }
1906
1907 #[test]
1908 fn test_key_order_respects_later_position_over_alphabetical() {
1909 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
1911 let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
1912 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1913 let result = rule.check(&ctx).unwrap();
1914
1915 assert!(result.is_empty());
1917 }
1918
1919 #[test]
1922 fn test_json_braces_in_string_values_extracts_all_keys() {
1923 let rule = create_enabled_rule();
1927 let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
1928 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1929 let result = rule.check(&ctx).unwrap();
1930
1931 assert!(
1933 result.is_empty(),
1934 "All keys should be extracted and recognized as sorted. Got: {result:?}"
1935 );
1936 }
1937
1938 #[test]
1939 fn test_json_braces_in_string_key_after_brace_value_detected() {
1940 let rule = create_enabled_rule();
1942 let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
1945 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1946 let result = rule.check(&ctx).unwrap();
1947
1948 assert_eq!(
1951 result.len(),
1952 1,
1953 "Should detect unsorted keys after brace-containing string value"
1954 );
1955 assert!(
1956 result[0].message.contains("'author' should come before 'description'"),
1957 "Should report author before description. Got: {}",
1958 result[0].message
1959 );
1960 }
1961
1962 #[test]
1963 fn test_json_brackets_in_string_values() {
1964 let rule = create_enabled_rule();
1966 let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
1967 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1968 let result = rule.check(&ctx).unwrap();
1969
1970 assert_eq!(
1972 result.len(),
1973 1,
1974 "Should detect unsorted keys despite brackets in string values"
1975 );
1976 assert!(
1977 result[0].message.contains("'author' should come before 'description'"),
1978 "Got: {}",
1979 result[0].message
1980 );
1981 }
1982
1983 #[test]
1984 fn test_json_escaped_quotes_in_values() {
1985 let rule = create_enabled_rule();
1987 let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
1988 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1989 let result = rule.check(&ctx).unwrap();
1990
1991 assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
1993 assert!(
1994 result[0].message.contains("'author' should come before 'title'"),
1995 "Got: {}",
1996 result[0].message
1997 );
1998 }
1999
2000 #[test]
2001 fn test_json_multiple_braces_in_string() {
2002 let rule = create_enabled_rule();
2004 let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2005 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2006 let result = rule.check(&ctx).unwrap();
2007
2008 assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
2010 assert!(
2011 result[0].message.contains("'author' should come before 'pattern'"),
2012 "Got: {}",
2013 result[0].message
2014 );
2015 }
2016
2017 #[test]
2018 fn test_key_order_detects_wrong_custom_order() {
2019 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2021 let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
2022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2023 let result = rule.check(&ctx).unwrap();
2024
2025 assert_eq!(result.len(), 1);
2026 assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
2027 }
2028}