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 if ctx.is_rule_disabled(self.name(), 2) {
397 return Ok(content.to_string());
398 }
399
400 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
401
402 match fm_type {
403 FrontMatterType::Yaml => self.fix_yaml(content),
404 FrontMatterType::Toml => self.fix_toml(content),
405 FrontMatterType::Json => self.fix_json(content),
406 _ => Ok(content.to_string()),
407 }
408 }
409
410 fn category(&self) -> RuleCategory {
411 RuleCategory::FrontMatter
412 }
413
414 fn as_any(&self) -> &dyn std::any::Any {
415 self
416 }
417
418 fn default_config_section(&self) -> Option<(String, toml::Value)> {
419 let table = crate::rule_config_serde::config_schema_table(&MD072Config::default())?;
420 Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
421 }
422
423 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
424 where
425 Self: Sized,
426 {
427 let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
428 Box::new(Self::from_config_struct(rule_config))
429 }
430}
431
432impl MD072FrontmatterKeySort {
433 fn fix_yaml(&self, content: &str) -> Result<String, LintError> {
434 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
435 if frontmatter_lines.is_empty() {
436 return Ok(content.to_string());
437 }
438
439 if Self::has_comments(&frontmatter_lines) {
441 return Ok(content.to_string());
442 }
443
444 let keys = Self::extract_yaml_keys(&frontmatter_lines);
445 let key_order = self.config.key_order.as_deref();
446 if Self::are_indexed_keys_sorted(&keys, key_order) {
447 return Ok(content.to_string());
448 }
449
450 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
453
454 for (i, (line_idx, key)) in keys.iter().enumerate() {
455 let start = *line_idx;
456 let end = if i + 1 < keys.len() {
457 keys[i + 1].0
458 } else {
459 frontmatter_lines.len()
460 };
461
462 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
463 key_blocks.push((key.clone(), block_lines));
464 }
465
466 Self::sort_keys_by_order(&mut key_blocks, key_order);
468
469 let content_lines: Vec<&str> = content.lines().collect();
471 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
472
473 let mut result = String::new();
474 result.push_str("---\n");
475 for (_, lines) in &key_blocks {
476 for line in lines {
477 result.push_str(line);
478 result.push('\n');
479 }
480 }
481 result.push_str("---");
482
483 if fm_end < content_lines.len() {
484 result.push('\n');
485 result.push_str(&content_lines[fm_end..].join("\n"));
486 }
487
488 Ok(result)
489 }
490
491 fn fix_toml(&self, content: &str) -> Result<String, LintError> {
492 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
493 if frontmatter_lines.is_empty() {
494 return Ok(content.to_string());
495 }
496
497 if Self::has_comments(&frontmatter_lines) {
499 return Ok(content.to_string());
500 }
501
502 let keys = Self::extract_toml_keys(&frontmatter_lines);
503 let key_order = self.config.key_order.as_deref();
504 if Self::are_indexed_keys_sorted(&keys, key_order) {
505 return Ok(content.to_string());
506 }
507
508 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
511
512 for (i, (line_idx, key)) in keys.iter().enumerate() {
513 let start = *line_idx;
514 let end = if i + 1 < keys.len() {
515 keys[i + 1].0
516 } else {
517 frontmatter_lines.len()
518 };
519
520 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
521 key_blocks.push((key.clone(), block_lines));
522 }
523
524 Self::sort_keys_by_order(&mut key_blocks, key_order);
526
527 let content_lines: Vec<&str> = content.lines().collect();
529 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
530
531 let mut result = String::new();
532 result.push_str("+++\n");
533 for (_, lines) in &key_blocks {
534 for line in lines {
535 result.push_str(line);
536 result.push('\n');
537 }
538 }
539 result.push_str("+++");
540
541 if fm_end < content_lines.len() {
542 result.push('\n');
543 result.push_str(&content_lines[fm_end..].join("\n"));
544 }
545
546 Ok(result)
547 }
548
549 fn fix_json(&self, content: &str) -> Result<String, LintError> {
550 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
551 if frontmatter_lines.is_empty() {
552 return Ok(content.to_string());
553 }
554
555 let keys = Self::extract_json_keys(&frontmatter_lines);
556 let key_order = self.config.key_order.as_deref();
557
558 if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
559 return Ok(content.to_string());
560 }
561
562 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
564
565 match serde_json::from_str::<serde_json::Value>(&json_content) {
567 Ok(serde_json::Value::Object(map)) => {
568 let mut sorted_map = serde_json::Map::new();
570 let mut keys: Vec<_> = map.keys().cloned().collect();
571 keys.sort_by(|a, b| {
572 let pos_a = Self::key_sort_position(a, key_order);
573 let pos_b = Self::key_sort_position(b, key_order);
574 pos_a.cmp(&pos_b)
575 });
576
577 for key in keys {
578 if let Some(value) = map.get(&key) {
579 sorted_map.insert(key, value.clone());
580 }
581 }
582
583 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
584 Ok(sorted_json) => {
585 let lines: Vec<&str> = content.lines().collect();
586 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
587
588 let mut result = String::new();
591 result.push_str(&sorted_json);
592
593 if fm_end < lines.len() {
594 result.push('\n');
595 result.push_str(&lines[fm_end..].join("\n"));
596 }
597
598 Ok(result)
599 }
600 Err(_) => Ok(content.to_string()),
601 }
602 }
603 _ => Ok(content.to_string()),
604 }
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 use crate::lint_context::LintContext;
612
613 fn create_enabled_rule() -> MD072FrontmatterKeySort {
615 MD072FrontmatterKeySort::from_config_struct(MD072Config {
616 enabled: true,
617 key_order: None,
618 })
619 }
620
621 fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
623 MD072FrontmatterKeySort::from_config_struct(MD072Config {
624 enabled: true,
625 key_order: Some(keys.into_iter().map(String::from).collect()),
626 })
627 }
628
629 #[test]
632 fn test_enabled_via_config() {
633 let rule = create_enabled_rule();
634 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
635 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
636 let result = rule.check(&ctx).unwrap();
637
638 assert_eq!(result.len(), 1);
640 }
641
642 #[test]
645 fn test_no_frontmatter() {
646 let rule = create_enabled_rule();
647 let content = "# Heading\n\nContent.";
648 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
649 let result = rule.check(&ctx).unwrap();
650
651 assert!(result.is_empty());
652 }
653
654 #[test]
655 fn test_yaml_sorted_keys() {
656 let rule = create_enabled_rule();
657 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
659 let result = rule.check(&ctx).unwrap();
660
661 assert!(result.is_empty());
662 }
663
664 #[test]
665 fn test_yaml_unsorted_keys() {
666 let rule = create_enabled_rule();
667 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
668 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
669 let result = rule.check(&ctx).unwrap();
670
671 assert_eq!(result.len(), 1);
672 assert!(result[0].message.contains("YAML"));
673 assert!(result[0].message.contains("not sorted"));
674 assert!(result[0].message.contains("'author' should come before 'title'"));
676 }
677
678 #[test]
679 fn test_yaml_case_insensitive_sort() {
680 let rule = create_enabled_rule();
681 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let result = rule.check(&ctx).unwrap();
684
685 assert!(result.is_empty());
687 }
688
689 #[test]
690 fn test_yaml_fix_sorts_keys() {
691 let rule = create_enabled_rule();
692 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
693 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
694 let fixed = rule.fix(&ctx).unwrap();
695
696 let author_pos = fixed.find("author:").unwrap();
698 let title_pos = fixed.find("title:").unwrap();
699 assert!(author_pos < title_pos);
700 }
701
702 #[test]
703 fn test_yaml_no_fix_with_comments() {
704 let rule = create_enabled_rule();
705 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
707 let result = rule.check(&ctx).unwrap();
708
709 assert_eq!(result.len(), 1);
710 assert!(result[0].message.contains("auto-fix unavailable"));
711 assert!(result[0].fix.is_none());
712
713 let fixed = rule.fix(&ctx).unwrap();
715 assert_eq!(fixed, content);
716 }
717
718 #[test]
719 fn test_yaml_single_key() {
720 let rule = create_enabled_rule();
721 let content = "---\ntitle: Test\n---\n\n# Heading";
722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let result = rule.check(&ctx).unwrap();
724
725 assert!(result.is_empty());
727 }
728
729 #[test]
730 fn test_yaml_nested_keys_ignored() {
731 let rule = create_enabled_rule();
732 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
734 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
735 let result = rule.check(&ctx).unwrap();
736
737 assert!(result.is_empty());
739 }
740
741 #[test]
742 fn test_yaml_fix_idempotent() {
743 let rule = create_enabled_rule();
744 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let fixed_once = rule.fix(&ctx).unwrap();
747
748 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
749 let fixed_twice = rule.fix(&ctx2).unwrap();
750
751 assert_eq!(fixed_once, fixed_twice);
752 }
753
754 #[test]
755 fn test_yaml_complex_values() {
756 let rule = create_enabled_rule();
757 let content =
759 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
761 let result = rule.check(&ctx).unwrap();
762
763 assert!(result.is_empty());
765 }
766
767 #[test]
770 fn test_toml_sorted_keys() {
771 let rule = create_enabled_rule();
772 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
773 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
774 let result = rule.check(&ctx).unwrap();
775
776 assert!(result.is_empty());
777 }
778
779 #[test]
780 fn test_toml_unsorted_keys() {
781 let rule = create_enabled_rule();
782 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
783 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784 let result = rule.check(&ctx).unwrap();
785
786 assert_eq!(result.len(), 1);
787 assert!(result[0].message.contains("TOML"));
788 assert!(result[0].message.contains("not sorted"));
789 }
790
791 #[test]
792 fn test_toml_fix_sorts_keys() {
793 let rule = create_enabled_rule();
794 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
796 let fixed = rule.fix(&ctx).unwrap();
797
798 let author_pos = fixed.find("author").unwrap();
800 let title_pos = fixed.find("title").unwrap();
801 assert!(author_pos < title_pos);
802 }
803
804 #[test]
805 fn test_toml_no_fix_with_comments() {
806 let rule = create_enabled_rule();
807 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
808 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
809 let result = rule.check(&ctx).unwrap();
810
811 assert_eq!(result.len(), 1);
812 assert!(result[0].message.contains("auto-fix unavailable"));
813
814 let fixed = rule.fix(&ctx).unwrap();
816 assert_eq!(fixed, content);
817 }
818
819 #[test]
822 fn test_json_sorted_keys() {
823 let rule = create_enabled_rule();
824 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
825 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826 let result = rule.check(&ctx).unwrap();
827
828 assert!(result.is_empty());
829 }
830
831 #[test]
832 fn test_json_unsorted_keys() {
833 let rule = create_enabled_rule();
834 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
835 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
836 let result = rule.check(&ctx).unwrap();
837
838 assert_eq!(result.len(), 1);
839 assert!(result[0].message.contains("JSON"));
840 assert!(result[0].message.contains("not sorted"));
841 }
842
843 #[test]
844 fn test_json_fix_sorts_keys() {
845 let rule = create_enabled_rule();
846 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
847 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
848 let fixed = rule.fix(&ctx).unwrap();
849
850 let author_pos = fixed.find("author").unwrap();
852 let title_pos = fixed.find("title").unwrap();
853 assert!(author_pos < title_pos);
854 }
855
856 #[test]
857 fn test_json_always_fixable() {
858 let rule = create_enabled_rule();
859 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
861 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
862 let result = rule.check(&ctx).unwrap();
863
864 assert_eq!(result.len(), 1);
865 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
867 }
868
869 #[test]
872 fn test_empty_content() {
873 let rule = create_enabled_rule();
874 let content = "";
875 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
876 let result = rule.check(&ctx).unwrap();
877
878 assert!(result.is_empty());
879 }
880
881 #[test]
882 fn test_empty_frontmatter() {
883 let rule = create_enabled_rule();
884 let content = "---\n---\n\n# Heading";
885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
886 let result = rule.check(&ctx).unwrap();
887
888 assert!(result.is_empty());
889 }
890
891 #[test]
892 fn test_toml_nested_tables_ignored() {
893 let rule = create_enabled_rule();
895 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
896 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
897 let result = rule.check(&ctx).unwrap();
898
899 assert_eq!(result.len(), 1);
901 assert!(result[0].message.contains("'sort_by' should come before 'title'"));
903 assert!(!result[0].message.contains("we_have_extra"));
904 }
905
906 #[test]
907 fn test_toml_nested_taxonomies_ignored() {
908 let rule = create_enabled_rule();
910 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
911 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
912 let result = rule.check(&ctx).unwrap();
913
914 assert_eq!(result.len(), 1);
916 assert!(result[0].message.contains("'date' should come before 'title'"));
918 assert!(!result[0].message.contains("categories"));
919 assert!(!result[0].message.contains("tags"));
920 }
921
922 #[test]
925 fn test_yaml_unicode_keys() {
926 let rule = create_enabled_rule();
927 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
929 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
930 let result = rule.check(&ctx).unwrap();
931
932 assert_eq!(result.len(), 1);
934 }
935
936 #[test]
937 fn test_yaml_keys_with_special_characters() {
938 let rule = create_enabled_rule();
939 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
942 let result = rule.check(&ctx).unwrap();
943
944 assert!(result.is_empty());
946 }
947
948 #[test]
949 fn test_yaml_keys_with_numbers() {
950 let rule = create_enabled_rule();
951 let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
952 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
953 let result = rule.check(&ctx).unwrap();
954
955 assert!(result.is_empty());
957 }
958
959 #[test]
960 fn test_yaml_multiline_string_block_literal() {
961 let rule = create_enabled_rule();
962 let content =
963 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
965 let result = rule.check(&ctx).unwrap();
966
967 assert_eq!(result.len(), 1);
969 assert!(result[0].message.contains("'author' should come before 'title'"));
970 }
971
972 #[test]
973 fn test_yaml_multiline_string_folded() {
974 let rule = create_enabled_rule();
975 let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\n---\n\n# Heading";
976 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
977 let result = rule.check(&ctx).unwrap();
978
979 assert_eq!(result.len(), 1);
981 }
982
983 #[test]
984 fn test_yaml_fix_preserves_multiline_values() {
985 let rule = create_enabled_rule();
986 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988 let fixed = rule.fix(&ctx).unwrap();
989
990 let desc_pos = fixed.find("description").unwrap();
992 let title_pos = fixed.find("title").unwrap();
993 assert!(desc_pos < title_pos);
994 }
995
996 #[test]
997 fn test_yaml_quoted_keys() {
998 let rule = create_enabled_rule();
999 let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
1000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1001 let result = rule.check(&ctx).unwrap();
1002
1003 assert!(result.is_empty());
1005 }
1006
1007 #[test]
1008 fn test_yaml_duplicate_keys() {
1009 let rule = create_enabled_rule();
1011 let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1013 let result = rule.check(&ctx).unwrap();
1014
1015 assert_eq!(result.len(), 1);
1017 }
1018
1019 #[test]
1020 fn test_toml_inline_table() {
1021 let rule = create_enabled_rule();
1022 let content =
1023 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1024 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025 let result = rule.check(&ctx).unwrap();
1026
1027 assert!(result.is_empty());
1029 }
1030
1031 #[test]
1032 fn test_toml_array_of_tables() {
1033 let rule = create_enabled_rule();
1034 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1035 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1036 let result = rule.check(&ctx).unwrap();
1037
1038 assert_eq!(result.len(), 1);
1040 assert!(result[0].message.contains("'date' should come before 'title'"));
1042 }
1043
1044 #[test]
1045 fn test_json_nested_objects() {
1046 let rule = create_enabled_rule();
1047 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1048 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049 let result = rule.check(&ctx).unwrap();
1050
1051 assert!(result.is_empty());
1053 }
1054
1055 #[test]
1056 fn test_json_arrays() {
1057 let rule = create_enabled_rule();
1058 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1059 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1060 let result = rule.check(&ctx).unwrap();
1061
1062 assert_eq!(result.len(), 1);
1064 }
1065
1066 #[test]
1067 fn test_fix_preserves_content_after_frontmatter() {
1068 let rule = create_enabled_rule();
1069 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071 let fixed = rule.fix(&ctx).unwrap();
1072
1073 assert!(fixed.contains("# Heading"));
1075 assert!(fixed.contains("Paragraph 1."));
1076 assert!(fixed.contains("- List item"));
1077 assert!(fixed.contains("- Another item"));
1078 }
1079
1080 #[test]
1081 fn test_fix_yaml_produces_valid_yaml() {
1082 let rule = create_enabled_rule();
1083 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1084 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1085 let fixed = rule.fix(&ctx).unwrap();
1086
1087 let lines: Vec<&str> = fixed.lines().collect();
1090 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1091 let fm_content: String = lines[1..fm_end].join("\n");
1092
1093 let parsed: Result<serde_yml::Value, _> = serde_yml::from_str(&fm_content);
1095 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1096 }
1097
1098 #[test]
1099 fn test_fix_toml_produces_valid_toml() {
1100 let rule = create_enabled_rule();
1101 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1102 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1103 let fixed = rule.fix(&ctx).unwrap();
1104
1105 let lines: Vec<&str> = fixed.lines().collect();
1107 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1108 let fm_content: String = lines[1..fm_end].join("\n");
1109
1110 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1112 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1113 }
1114
1115 #[test]
1116 fn test_fix_json_produces_valid_json() {
1117 let rule = create_enabled_rule();
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 fixed = rule.fix(&ctx).unwrap();
1121
1122 let json_end = fixed.find("\n\n").unwrap();
1124 let json_content = &fixed[..json_end];
1125
1126 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1128 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1129 }
1130
1131 #[test]
1132 fn test_many_keys_performance() {
1133 let rule = create_enabled_rule();
1134 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1136 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1138
1139 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1140 let result = rule.check(&ctx).unwrap();
1141
1142 assert_eq!(result.len(), 1);
1144 }
1145
1146 #[test]
1147 fn test_yaml_empty_value() {
1148 let rule = create_enabled_rule();
1149 let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1150 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1151 let result = rule.check(&ctx).unwrap();
1152
1153 assert_eq!(result.len(), 1);
1155 }
1156
1157 #[test]
1158 fn test_yaml_null_value() {
1159 let rule = create_enabled_rule();
1160 let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1161 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1162 let result = rule.check(&ctx).unwrap();
1163
1164 assert_eq!(result.len(), 1);
1165 }
1166
1167 #[test]
1168 fn test_yaml_boolean_values() {
1169 let rule = create_enabled_rule();
1170 let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1171 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1172 let result = rule.check(&ctx).unwrap();
1173
1174 assert_eq!(result.len(), 1);
1176 }
1177
1178 #[test]
1179 fn test_toml_boolean_values() {
1180 let rule = create_enabled_rule();
1181 let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183 let result = rule.check(&ctx).unwrap();
1184
1185 assert_eq!(result.len(), 1);
1186 }
1187
1188 #[test]
1189 fn test_yaml_list_at_top_level() {
1190 let rule = create_enabled_rule();
1191 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1192 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193 let result = rule.check(&ctx).unwrap();
1194
1195 assert_eq!(result.len(), 1);
1197 }
1198
1199 #[test]
1200 fn test_three_keys_all_orderings() {
1201 let rule = create_enabled_rule();
1202
1203 let orderings = [
1205 ("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), ];
1212
1213 for (name, content, should_pass) in orderings {
1214 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1215 let result = rule.check(&ctx).unwrap();
1216 assert_eq!(
1217 result.is_empty(),
1218 should_pass,
1219 "Ordering {name} should {} pass",
1220 if should_pass { "" } else { "not" }
1221 );
1222 }
1223 }
1224
1225 #[test]
1226 fn test_crlf_line_endings() {
1227 let rule = create_enabled_rule();
1228 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1229 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1230 let result = rule.check(&ctx).unwrap();
1231
1232 assert_eq!(result.len(), 1);
1234 }
1235
1236 #[test]
1237 fn test_json_escaped_quotes_in_keys() {
1238 let rule = create_enabled_rule();
1239 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1241 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1242 let result = rule.check(&ctx).unwrap();
1243
1244 assert_eq!(result.len(), 1);
1246 }
1247
1248 #[test]
1251 fn test_warning_fix_yaml_sorts_keys() {
1252 let rule = create_enabled_rule();
1253 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1255 let warnings = rule.check(&ctx).unwrap();
1256
1257 assert_eq!(warnings.len(), 1);
1258 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1259
1260 let fix = warnings[0].fix.as_ref().unwrap();
1261 assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1262
1263 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1265
1266 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1268 let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1269 assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1270 }
1271
1272 #[test]
1273 fn test_warning_fix_preserves_yaml_list_indentation() {
1274 let rule = create_enabled_rule();
1275 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1276 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277 let warnings = rule.check(&ctx).unwrap();
1278
1279 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1280
1281 assert!(
1283 fixed.contains(" - hello"),
1284 "List indentation should be preserved: {fixed}"
1285 );
1286 assert!(
1287 fixed.contains(" - world"),
1288 "List indentation should be preserved: {fixed}"
1289 );
1290 }
1291
1292 #[test]
1293 fn test_warning_fix_preserves_nested_object_indentation() {
1294 let rule = create_enabled_rule();
1295 let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
1296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1297 let warnings = rule.check(&ctx).unwrap();
1298
1299 assert_eq!(warnings.len(), 1);
1300 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1301
1302 let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1304 let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1305 assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1306
1307 assert!(
1309 fixed.contains(" nested_key: nested_value"),
1310 "Nested object indentation should be preserved: {fixed}"
1311 );
1312 assert!(
1313 fixed.contains(" another: 123"),
1314 "Nested object indentation should be preserved: {fixed}"
1315 );
1316 }
1317
1318 #[test]
1319 fn test_warning_fix_preserves_deeply_nested_structure() {
1320 let rule = create_enabled_rule();
1321 let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
1322 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1323 let warnings = rule.check(&ctx).unwrap();
1324
1325 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1326
1327 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1329 let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1330 assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1331
1332 assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
1334 assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
1335 assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
1336 assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
1337 }
1338
1339 #[test]
1340 fn test_warning_fix_toml_sorts_keys() {
1341 let rule = create_enabled_rule();
1342 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1344 let warnings = rule.check(&ctx).unwrap();
1345
1346 assert_eq!(warnings.len(), 1);
1347 assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1348
1349 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1350
1351 let author_pos = fixed.find("author").expect("author should exist");
1353 let title_pos = fixed.find("title").expect("title should exist");
1354 assert!(author_pos < title_pos, "author should come before title");
1355 }
1356
1357 #[test]
1358 fn test_warning_fix_json_sorts_keys() {
1359 let rule = create_enabled_rule();
1360 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1361 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1362 let warnings = rule.check(&ctx).unwrap();
1363
1364 assert_eq!(warnings.len(), 1);
1365 assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1366
1367 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1368
1369 let author_pos = fixed.find("author").expect("author should exist");
1371 let title_pos = fixed.find("title").expect("title should exist");
1372 assert!(author_pos < title_pos, "author should come before title");
1373 }
1374
1375 #[test]
1376 fn test_warning_fix_no_fix_when_comments_present() {
1377 let rule = create_enabled_rule();
1378 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380 let warnings = rule.check(&ctx).unwrap();
1381
1382 assert_eq!(warnings.len(), 1);
1383 assert!(
1384 warnings[0].fix.is_none(),
1385 "Warning should NOT have a fix when comments are present"
1386 );
1387 assert!(
1388 warnings[0].message.contains("auto-fix unavailable"),
1389 "Message should indicate auto-fix is unavailable"
1390 );
1391 }
1392
1393 #[test]
1394 fn test_warning_fix_preserves_content_after_frontmatter() {
1395 let rule = create_enabled_rule();
1396 let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1397 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1398 let warnings = rule.check(&ctx).unwrap();
1399
1400 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1401
1402 assert!(fixed.contains("# Heading"), "Heading should be preserved");
1404 assert!(
1405 fixed.contains("Paragraph with content."),
1406 "Paragraph should be preserved"
1407 );
1408 assert!(fixed.contains("- List item"), "List item should be preserved");
1409 }
1410
1411 #[test]
1412 fn test_warning_fix_idempotent() {
1413 let rule = create_enabled_rule();
1414 let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1416 let warnings = rule.check(&ctx).unwrap();
1417
1418 let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1419
1420 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1422 let warnings2 = rule.check(&ctx2).unwrap();
1423
1424 assert!(
1425 warnings2.is_empty(),
1426 "After fixing, no more warnings should be produced"
1427 );
1428 }
1429
1430 #[test]
1431 fn test_warning_fix_preserves_multiline_block_literal() {
1432 let rule = create_enabled_rule();
1433 let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
1434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1435 let warnings = rule.check(&ctx).unwrap();
1436
1437 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1438
1439 assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1441 assert!(
1442 fixed.contains(" Line 1 of block"),
1443 "Block literal line 1 should be preserved with indent"
1444 );
1445 assert!(
1446 fixed.contains(" Line 2 of block"),
1447 "Block literal line 2 should be preserved with indent"
1448 );
1449 }
1450
1451 #[test]
1452 fn test_warning_fix_preserves_folded_string() {
1453 let rule = create_enabled_rule();
1454 let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\n";
1455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1456 let warnings = rule.check(&ctx).unwrap();
1457
1458 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1459
1460 assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1462 assert!(
1463 fixed.contains(" Folded line 1"),
1464 "Folded line 1 should be preserved with indent"
1465 );
1466 assert!(
1467 fixed.contains(" Folded line 2"),
1468 "Folded line 2 should be preserved with indent"
1469 );
1470 }
1471
1472 #[test]
1473 fn test_warning_fix_preserves_4_space_indentation() {
1474 let rule = create_enabled_rule();
1475 let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
1477 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1478 let warnings = rule.check(&ctx).unwrap();
1479
1480 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1481
1482 assert!(
1484 fixed.contains(" nested: with_4_spaces"),
1485 "4-space indentation should be preserved: {fixed}"
1486 );
1487 assert!(
1488 fixed.contains(" another: value"),
1489 "4-space indentation should be preserved: {fixed}"
1490 );
1491 }
1492
1493 #[test]
1494 fn test_warning_fix_preserves_tab_indentation() {
1495 let rule = create_enabled_rule();
1496 let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1499 let warnings = rule.check(&ctx).unwrap();
1500
1501 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1502
1503 assert!(
1505 fixed.contains("\tnested: with_tab"),
1506 "Tab indentation should be preserved: {fixed}"
1507 );
1508 assert!(
1509 fixed.contains("\tanother: value"),
1510 "Tab indentation should be preserved: {fixed}"
1511 );
1512 }
1513
1514 #[test]
1515 fn test_warning_fix_preserves_inline_list() {
1516 let rule = create_enabled_rule();
1517 let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1519 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1520 let warnings = rule.check(&ctx).unwrap();
1521
1522 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1523
1524 assert!(
1526 fixed.contains("aaa: [one, two, three]"),
1527 "Inline list should be preserved exactly: {fixed}"
1528 );
1529 }
1530
1531 #[test]
1532 fn test_warning_fix_preserves_quoted_strings() {
1533 let rule = create_enabled_rule();
1534 let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1536 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1537 let warnings = rule.check(&ctx).unwrap();
1538
1539 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1540
1541 assert!(
1543 fixed.contains("aaa: \"value with: colon\""),
1544 "Double-quoted string should be preserved: {fixed}"
1545 );
1546 assert!(
1547 fixed.contains("bbb: 'single quotes'"),
1548 "Single-quoted string should be preserved: {fixed}"
1549 );
1550 }
1551
1552 #[test]
1555 fn test_yaml_custom_key_order_sorted() {
1556 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1558 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1559 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1560 let result = rule.check(&ctx).unwrap();
1561
1562 assert!(result.is_empty());
1564 }
1565
1566 #[test]
1567 fn test_yaml_custom_key_order_unsorted() {
1568 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1570 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1571 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1572 let result = rule.check(&ctx).unwrap();
1573
1574 assert_eq!(result.len(), 1);
1575 assert!(result[0].message.contains("'date' should come before 'author'"));
1577 }
1578
1579 #[test]
1580 fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1581 let rule = create_rule_with_key_order(vec!["title"]);
1583 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1584 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1585 let result = rule.check(&ctx).unwrap();
1586
1587 assert!(result.is_empty());
1590 }
1591
1592 #[test]
1593 fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1594 let rule = create_rule_with_key_order(vec!["title"]);
1596 let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1597 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1598 let result = rule.check(&ctx).unwrap();
1599
1600 assert_eq!(result.len(), 1);
1602 assert!(result[0].message.contains("'author' should come before 'zebra'"));
1603 }
1604
1605 #[test]
1606 fn test_yaml_custom_key_order_fix() {
1607 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1608 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1609 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1610 let fixed = rule.fix(&ctx).unwrap();
1611
1612 let title_pos = fixed.find("title:").unwrap();
1614 let date_pos = fixed.find("date:").unwrap();
1615 let author_pos = fixed.find("author:").unwrap();
1616 assert!(
1617 title_pos < date_pos && date_pos < author_pos,
1618 "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1619 );
1620 }
1621
1622 #[test]
1623 fn test_yaml_custom_key_order_fix_with_unlisted() {
1624 let rule = create_rule_with_key_order(vec!["title", "author"]);
1626 let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1628 let fixed = rule.fix(&ctx).unwrap();
1629
1630 let title_pos = fixed.find("title:").unwrap();
1632 let author_pos = fixed.find("author:").unwrap();
1633 let aardvark_pos = fixed.find("aardvark:").unwrap();
1634 let zebra_pos = fixed.find("zebra:").unwrap();
1635
1636 assert!(
1637 title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1638 "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1639 );
1640 }
1641
1642 #[test]
1643 fn test_toml_custom_key_order_sorted() {
1644 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1645 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1647 let result = rule.check(&ctx).unwrap();
1648
1649 assert!(result.is_empty());
1650 }
1651
1652 #[test]
1653 fn test_toml_custom_key_order_unsorted() {
1654 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1655 let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1656 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1657 let result = rule.check(&ctx).unwrap();
1658
1659 assert_eq!(result.len(), 1);
1660 assert!(result[0].message.contains("TOML"));
1661 }
1662
1663 #[test]
1664 fn test_json_custom_key_order_sorted() {
1665 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1666 let content = "{\n \"title\": \"Test\",\n \"date\": \"2024-01-01\",\n \"author\": \"John\"\n}\n\n# Heading";
1667 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1668 let result = rule.check(&ctx).unwrap();
1669
1670 assert!(result.is_empty());
1671 }
1672
1673 #[test]
1674 fn test_json_custom_key_order_unsorted() {
1675 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1676 let content = "{\n \"author\": \"John\",\n \"title\": \"Test\",\n \"date\": \"2024-01-01\"\n}\n\n# Heading";
1677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1678 let result = rule.check(&ctx).unwrap();
1679
1680 assert_eq!(result.len(), 1);
1681 assert!(result[0].message.contains("JSON"));
1682 }
1683
1684 #[test]
1685 fn test_key_order_case_insensitive_match() {
1686 let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1688 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1690 let result = rule.check(&ctx).unwrap();
1691
1692 assert!(result.is_empty());
1694 }
1695
1696 #[test]
1697 fn test_key_order_partial_match() {
1698 let rule = create_rule_with_key_order(vec!["title"]);
1700 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1702 let result = rule.check(&ctx).unwrap();
1703
1704 assert_eq!(result.len(), 1);
1715 assert!(result[0].message.contains("'author' should come before 'date'"));
1716 }
1717
1718 #[test]
1721 fn test_key_order_empty_array_falls_back_to_alphabetical() {
1722 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1724 enabled: true,
1725 key_order: Some(vec![]),
1726 });
1727 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1728 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1729 let result = rule.check(&ctx).unwrap();
1730
1731 assert_eq!(result.len(), 1);
1734 assert!(result[0].message.contains("'author' should come before 'title'"));
1735 }
1736
1737 #[test]
1738 fn test_key_order_single_key() {
1739 let rule = create_rule_with_key_order(vec!["title"]);
1741 let content = "---\ntitle: Test\n---\n\n# Heading";
1742 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1743 let result = rule.check(&ctx).unwrap();
1744
1745 assert!(result.is_empty());
1746 }
1747
1748 #[test]
1749 fn test_key_order_all_keys_specified() {
1750 let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
1752 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1753 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1754 let result = rule.check(&ctx).unwrap();
1755
1756 assert!(result.is_empty());
1757 }
1758
1759 #[test]
1760 fn test_key_order_no_keys_match() {
1761 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1763 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1764 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1765 let result = rule.check(&ctx).unwrap();
1766
1767 assert!(result.is_empty());
1770 }
1771
1772 #[test]
1773 fn test_key_order_no_keys_match_unsorted() {
1774 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1776 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1778 let result = rule.check(&ctx).unwrap();
1779
1780 assert_eq!(result.len(), 1);
1783 }
1784
1785 #[test]
1786 fn test_key_order_duplicate_keys_in_config() {
1787 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1789 enabled: true,
1790 key_order: Some(vec![
1791 "title".to_string(),
1792 "author".to_string(),
1793 "title".to_string(), ]),
1795 });
1796 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1797 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1798 let result = rule.check(&ctx).unwrap();
1799
1800 assert!(result.is_empty());
1802 }
1803
1804 #[test]
1805 fn test_key_order_with_comments_still_skips_fix() {
1806 let rule = create_rule_with_key_order(vec!["title", "author"]);
1808 let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
1809 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1810 let result = rule.check(&ctx).unwrap();
1811
1812 assert_eq!(result.len(), 1);
1814 assert!(result[0].message.contains("auto-fix unavailable"));
1815 assert!(result[0].fix.is_none());
1816 }
1817
1818 #[test]
1819 fn test_toml_custom_key_order_fix() {
1820 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1821 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1822 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1823 let fixed = rule.fix(&ctx).unwrap();
1824
1825 let title_pos = fixed.find("title").unwrap();
1827 let date_pos = fixed.find("date").unwrap();
1828 let author_pos = fixed.find("author").unwrap();
1829 assert!(
1830 title_pos < date_pos && date_pos < author_pos,
1831 "Fixed TOML should have keys in custom order. Got:\n{fixed}"
1832 );
1833 }
1834
1835 #[test]
1836 fn test_json_custom_key_order_fix() {
1837 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1838 let content = "{\n \"author\": \"John\",\n \"date\": \"2024-01-01\",\n \"title\": \"Test\"\n}\n\n# Heading";
1839 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1840 let fixed = rule.fix(&ctx).unwrap();
1841
1842 let title_pos = fixed.find("\"title\"").unwrap();
1844 let date_pos = fixed.find("\"date\"").unwrap();
1845 let author_pos = fixed.find("\"author\"").unwrap();
1846 assert!(
1847 title_pos < date_pos && date_pos < author_pos,
1848 "Fixed JSON should have keys in custom order. Got:\n{fixed}"
1849 );
1850 }
1851
1852 #[test]
1853 fn test_key_order_unicode_keys() {
1854 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1856 enabled: true,
1857 key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
1858 });
1859 let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
1860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1861 let result = rule.check(&ctx).unwrap();
1862
1863 assert!(result.is_empty());
1865 }
1866
1867 #[test]
1868 fn test_key_order_mixed_specified_and_unlisted_boundary() {
1869 let rule = create_rule_with_key_order(vec!["z_last_specified"]);
1871 let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
1872 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1873 let result = rule.check(&ctx).unwrap();
1874
1875 assert!(result.is_empty());
1878 }
1879
1880 #[test]
1881 fn test_key_order_fix_preserves_values() {
1882 let rule = create_rule_with_key_order(vec!["title", "tags"]);
1884 let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n\n# Heading";
1885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1886 let fixed = rule.fix(&ctx).unwrap();
1887
1888 let title_pos = fixed.find("title:").unwrap();
1890 let tags_pos = fixed.find("tags:").unwrap();
1891 assert!(title_pos < tags_pos, "title should come before tags");
1892
1893 assert!(fixed.contains("- rust"), "List items should be preserved");
1895 assert!(fixed.contains("- markdown"), "List items should be preserved");
1896 }
1897
1898 #[test]
1899 fn test_key_order_idempotent_fix() {
1900 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1902 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1904
1905 let fixed_once = rule.fix(&ctx).unwrap();
1906 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1907 let fixed_twice = rule.fix(&ctx2).unwrap();
1908
1909 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1910 }
1911
1912 #[test]
1913 fn test_key_order_respects_later_position_over_alphabetical() {
1914 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
1916 let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
1917 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1918 let result = rule.check(&ctx).unwrap();
1919
1920 assert!(result.is_empty());
1922 }
1923
1924 #[test]
1927 fn test_json_braces_in_string_values_extracts_all_keys() {
1928 let rule = create_enabled_rule();
1932 let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
1933 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1934 let result = rule.check(&ctx).unwrap();
1935
1936 assert!(
1938 result.is_empty(),
1939 "All keys should be extracted and recognized as sorted. Got: {result:?}"
1940 );
1941 }
1942
1943 #[test]
1944 fn test_json_braces_in_string_key_after_brace_value_detected() {
1945 let rule = create_enabled_rule();
1947 let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
1950 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1951 let result = rule.check(&ctx).unwrap();
1952
1953 assert_eq!(
1956 result.len(),
1957 1,
1958 "Should detect unsorted keys after brace-containing string value"
1959 );
1960 assert!(
1961 result[0].message.contains("'author' should come before 'description'"),
1962 "Should report author before description. Got: {}",
1963 result[0].message
1964 );
1965 }
1966
1967 #[test]
1968 fn test_json_brackets_in_string_values() {
1969 let rule = create_enabled_rule();
1971 let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
1972 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1973 let result = rule.check(&ctx).unwrap();
1974
1975 assert_eq!(
1977 result.len(),
1978 1,
1979 "Should detect unsorted keys despite brackets in string values"
1980 );
1981 assert!(
1982 result[0].message.contains("'author' should come before 'description'"),
1983 "Got: {}",
1984 result[0].message
1985 );
1986 }
1987
1988 #[test]
1989 fn test_json_escaped_quotes_in_values() {
1990 let rule = create_enabled_rule();
1992 let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
1993 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1994 let result = rule.check(&ctx).unwrap();
1995
1996 assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
1998 assert!(
1999 result[0].message.contains("'author' should come before 'title'"),
2000 "Got: {}",
2001 result[0].message
2002 );
2003 }
2004
2005 #[test]
2006 fn test_json_multiple_braces_in_string() {
2007 let rule = create_enabled_rule();
2009 let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2011 let result = rule.check(&ctx).unwrap();
2012
2013 assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
2015 assert!(
2016 result[0].message.contains("'author' should come before 'pattern'"),
2017 "Got: {}",
2018 result[0].message
2019 );
2020 }
2021
2022 #[test]
2023 fn test_key_order_detects_wrong_custom_order() {
2024 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2026 let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
2027 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2028 let result = rule.check(&ctx).unwrap();
2029
2030 assert_eq!(result.len(), 1);
2031 assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
2032 }
2033}