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<(usize, &'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].0, &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((key_idx, out_of_place, should_come_after)) =
259 Self::find_first_unsorted_indexed_pair(&keys, key_order)
260 else {
261 return Ok(warnings);
262 };
263 let key_line = key_idx + 2;
265
266 let has_comments = Self::has_comments(&frontmatter_lines);
267
268 let fix = if has_comments {
269 None
270 } else {
271 let fixed_content = self.fix_yaml(content);
273 if fixed_content != content {
274 Some(Fix::new(0..content.len(), fixed_content))
275 } else {
276 None
277 }
278 };
279
280 let message = if has_comments {
281 format!(
282 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
283 )
284 } else {
285 format!(
286 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
287 )
288 };
289
290 warnings.push(LintWarning {
291 rule_name: Some(self.name().to_string()),
292 message,
293 line: key_line,
294 column: 1,
295 end_line: key_line,
296 end_column: out_of_place.len() + 1,
297 severity: Severity::Warning,
298 fix,
299 });
300 }
301 FrontMatterType::Toml => {
302 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
303 if frontmatter_lines.is_empty() {
304 return Ok(warnings);
305 }
306
307 let keys = Self::extract_toml_keys(&frontmatter_lines);
308 let key_order = self.config.key_order.as_deref();
309 let Some((key_idx, out_of_place, should_come_after)) =
310 Self::find_first_unsorted_indexed_pair(&keys, key_order)
311 else {
312 return Ok(warnings);
313 };
314 let key_line = key_idx + 2;
315
316 let has_comments = Self::has_comments(&frontmatter_lines);
317
318 let fix = if has_comments {
319 None
320 } else {
321 let fixed_content = self.fix_toml(content);
323 if fixed_content != content {
324 Some(Fix::new(0..content.len(), fixed_content))
325 } else {
326 None
327 }
328 };
329
330 let message = if has_comments {
331 format!(
332 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
333 )
334 } else {
335 format!(
336 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
337 )
338 };
339
340 warnings.push(LintWarning {
341 rule_name: Some(self.name().to_string()),
342 message,
343 line: key_line,
344 column: 1,
345 end_line: key_line,
346 end_column: out_of_place.len() + 1,
347 severity: Severity::Warning,
348 fix,
349 });
350 }
351 FrontMatterType::Json => {
352 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
353 if frontmatter_lines.is_empty() {
354 return Ok(warnings);
355 }
356
357 let keys = Self::extract_json_keys(&frontmatter_lines);
358 let key_order = self.config.key_order.as_deref();
359 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_pair(&keys, key_order) else {
360 return Ok(warnings);
361 };
362
363 let fixed_content = self.fix_json(content);
365 let fix = if fixed_content != content {
366 Some(Fix::new(0..content.len(), fixed_content))
367 } else {
368 None
369 };
370
371 let message = format!(
372 "JSON frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
373 );
374
375 warnings.push(LintWarning {
376 rule_name: Some(self.name().to_string()),
377 message,
378 line: 2,
379 column: 1,
380 end_line: 2,
381 end_column: out_of_place.len() + 1,
382 severity: Severity::Warning,
383 fix,
384 });
385 }
386 _ => {
387 }
389 }
390
391 Ok(warnings)
392 }
393
394 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
395 let content = ctx.content;
396
397 if ctx.is_rule_disabled(self.name(), 2) {
399 return Ok(content.to_string());
400 }
401
402 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
403
404 Ok(match fm_type {
405 FrontMatterType::Yaml => self.fix_yaml(content),
406 FrontMatterType::Toml => self.fix_toml(content),
407 FrontMatterType::Json => self.fix_json(content),
408 _ => content.to_string(),
409 })
410 }
411
412 fn category(&self) -> RuleCategory {
413 RuleCategory::FrontMatter
414 }
415
416 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
417 ctx.content.is_empty()
418 || !ctx.content.starts_with("---") && !ctx.content.starts_with("+++") && !ctx.content.starts_with('{')
419 }
420
421 fn as_any(&self) -> &dyn std::any::Any {
422 self
423 }
424
425 fn default_config_section(&self) -> Option<(String, toml::Value)> {
426 let table = crate::rule_config_serde::config_schema_table(&MD072Config::default())?;
427 Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
428 }
429
430 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
431 where
432 Self: Sized,
433 {
434 let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
435 Box::new(Self::from_config_struct(rule_config))
436 }
437}
438
439impl MD072FrontmatterKeySort {
440 fn fix_yaml(&self, content: &str) -> String {
441 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
442 if frontmatter_lines.is_empty() {
443 return content.to_string();
444 }
445
446 if Self::has_comments(&frontmatter_lines) {
448 return content.to_string();
449 }
450
451 let keys = Self::extract_yaml_keys(&frontmatter_lines);
452 let key_order = self.config.key_order.as_deref();
453 if Self::are_indexed_keys_sorted(&keys, key_order) {
454 return content.to_string();
455 }
456
457 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
460
461 for (i, (line_idx, key)) in keys.iter().enumerate() {
462 let start = *line_idx;
463 let end = if i + 1 < keys.len() {
464 keys[i + 1].0
465 } else {
466 frontmatter_lines.len()
467 };
468
469 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
470 key_blocks.push((key.clone(), block_lines));
471 }
472
473 Self::sort_keys_by_order(&mut key_blocks, key_order);
475
476 let content_lines: Vec<&str> = content.lines().collect();
478 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
479
480 let mut result = String::new();
481 result.push_str("---\n");
482 for (_, lines) in &key_blocks {
483 for line in lines {
484 result.push_str(line);
485 result.push('\n');
486 }
487 }
488 result.push_str("---");
489
490 if fm_end < content_lines.len() {
491 result.push('\n');
492 result.push_str(&content_lines[fm_end..].join("\n"));
493 }
494
495 result
496 }
497
498 fn fix_toml(&self, content: &str) -> String {
499 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
500 if frontmatter_lines.is_empty() {
501 return content.to_string();
502 }
503
504 if Self::has_comments(&frontmatter_lines) {
506 return content.to_string();
507 }
508
509 let keys = Self::extract_toml_keys(&frontmatter_lines);
510 let key_order = self.config.key_order.as_deref();
511 if Self::are_indexed_keys_sorted(&keys, key_order) {
512 return content.to_string();
513 }
514
515 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
518
519 for (i, (line_idx, key)) in keys.iter().enumerate() {
520 let start = *line_idx;
521 let end = if i + 1 < keys.len() {
522 keys[i + 1].0
523 } else {
524 frontmatter_lines.len()
525 };
526
527 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
528 key_blocks.push((key.clone(), block_lines));
529 }
530
531 Self::sort_keys_by_order(&mut key_blocks, key_order);
533
534 let content_lines: Vec<&str> = content.lines().collect();
536 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
537
538 let mut result = String::new();
539 result.push_str("+++\n");
540 for (_, lines) in &key_blocks {
541 for line in lines {
542 result.push_str(line);
543 result.push('\n');
544 }
545 }
546 result.push_str("+++");
547
548 if fm_end < content_lines.len() {
549 result.push('\n');
550 result.push_str(&content_lines[fm_end..].join("\n"));
551 }
552
553 result
554 }
555
556 fn fix_json(&self, content: &str) -> String {
557 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
558 if frontmatter_lines.is_empty() {
559 return content.to_string();
560 }
561
562 let keys = Self::extract_json_keys(&frontmatter_lines);
563 let key_order = self.config.key_order.as_deref();
564
565 if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
566 return content.to_string();
567 }
568
569 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
571
572 match serde_json::from_str::<serde_json::Value>(&json_content) {
574 Ok(serde_json::Value::Object(map)) => {
575 let mut sorted_map = serde_json::Map::new();
577 let mut keys: Vec<_> = map.keys().cloned().collect();
578 keys.sort_by(|a, b| {
579 let pos_a = Self::key_sort_position(a, key_order);
580 let pos_b = Self::key_sort_position(b, key_order);
581 pos_a.cmp(&pos_b)
582 });
583
584 for key in keys {
585 if let Some(value) = map.get(&key) {
586 sorted_map.insert(key, value.clone());
587 }
588 }
589
590 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
591 Ok(sorted_json) => {
592 let lines: Vec<&str> = content.lines().collect();
593 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
594
595 let mut result = String::new();
598 result.push_str(&sorted_json);
599
600 if fm_end < lines.len() {
601 result.push('\n');
602 result.push_str(&lines[fm_end..].join("\n"));
603 }
604
605 result
606 }
607 Err(_) => content.to_string(),
608 }
609 }
610 _ => content.to_string(),
611 }
612 }
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use crate::lint_context::LintContext;
619
620 fn create_enabled_rule() -> MD072FrontmatterKeySort {
622 MD072FrontmatterKeySort::from_config_struct(MD072Config {
623 enabled: true,
624 key_order: None,
625 })
626 }
627
628 fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
630 MD072FrontmatterKeySort::from_config_struct(MD072Config {
631 enabled: true,
632 key_order: Some(keys.into_iter().map(String::from).collect()),
633 })
634 }
635
636 #[test]
639 fn test_enabled_via_config() {
640 let rule = create_enabled_rule();
641 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643 let result = rule.check(&ctx).unwrap();
644
645 assert_eq!(result.len(), 1);
647 }
648
649 #[test]
652 fn test_no_frontmatter() {
653 let rule = create_enabled_rule();
654 let content = "# Heading\n\nContent.";
655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
656 let result = rule.check(&ctx).unwrap();
657
658 assert!(result.is_empty());
659 }
660
661 #[test]
662 fn test_yaml_sorted_keys() {
663 let rule = create_enabled_rule();
664 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666 let result = rule.check(&ctx).unwrap();
667
668 assert!(result.is_empty());
669 }
670
671 #[test]
672 fn test_yaml_unsorted_keys() {
673 let rule = create_enabled_rule();
674 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
675 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
676 let result = rule.check(&ctx).unwrap();
677
678 assert_eq!(result.len(), 1);
679 assert!(result[0].message.contains("YAML"));
680 assert!(result[0].message.contains("not sorted"));
681 assert!(result[0].message.contains("'author' should come before 'title'"));
683 }
684
685 #[test]
686 fn test_yaml_case_insensitive_sort() {
687 let rule = create_enabled_rule();
688 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690 let result = rule.check(&ctx).unwrap();
691
692 assert!(result.is_empty());
694 }
695
696 #[test]
697 fn test_yaml_fix_sorts_keys() {
698 let rule = create_enabled_rule();
699 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let fixed = rule.fix(&ctx).unwrap();
702
703 let author_pos = fixed.find("author:").unwrap();
705 let title_pos = fixed.find("title:").unwrap();
706 assert!(author_pos < title_pos);
707 }
708
709 #[test]
710 fn test_yaml_no_fix_with_comments() {
711 let rule = create_enabled_rule();
712 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714 let result = rule.check(&ctx).unwrap();
715
716 assert_eq!(result.len(), 1);
717 assert!(result[0].message.contains("auto-fix unavailable"));
718 assert!(result[0].fix.is_none());
719
720 let fixed = rule.fix(&ctx).unwrap();
722 assert_eq!(fixed, content);
723 }
724
725 #[test]
726 fn test_yaml_single_key() {
727 let rule = create_enabled_rule();
728 let content = "---\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_nested_keys_ignored() {
738 let rule = create_enabled_rule();
739 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
741 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742 let result = rule.check(&ctx).unwrap();
743
744 assert!(result.is_empty());
746 }
747
748 #[test]
749 fn test_yaml_fix_idempotent() {
750 let rule = create_enabled_rule();
751 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753 let fixed_once = rule.fix(&ctx).unwrap();
754
755 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
756 let fixed_twice = rule.fix(&ctx2).unwrap();
757
758 assert_eq!(fixed_once, fixed_twice);
759 }
760
761 #[test]
762 fn test_yaml_complex_values() {
763 let rule = create_enabled_rule();
764 let content =
766 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768 let result = rule.check(&ctx).unwrap();
769
770 assert!(result.is_empty());
772 }
773
774 #[test]
777 fn test_toml_sorted_keys() {
778 let rule = create_enabled_rule();
779 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
780 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781 let result = rule.check(&ctx).unwrap();
782
783 assert!(result.is_empty());
784 }
785
786 #[test]
787 fn test_toml_unsorted_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 result = rule.check(&ctx).unwrap();
792
793 assert_eq!(result.len(), 1);
794 assert!(result[0].message.contains("TOML"));
795 assert!(result[0].message.contains("not sorted"));
796 }
797
798 #[test]
799 fn test_toml_fix_sorts_keys() {
800 let rule = create_enabled_rule();
801 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803 let fixed = rule.fix(&ctx).unwrap();
804
805 let author_pos = fixed.find("author").unwrap();
807 let title_pos = fixed.find("title").unwrap();
808 assert!(author_pos < title_pos);
809 }
810
811 #[test]
812 fn test_toml_no_fix_with_comments() {
813 let rule = create_enabled_rule();
814 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816 let result = rule.check(&ctx).unwrap();
817
818 assert_eq!(result.len(), 1);
819 assert!(result[0].message.contains("auto-fix unavailable"));
820
821 let fixed = rule.fix(&ctx).unwrap();
823 assert_eq!(fixed, content);
824 }
825
826 #[test]
829 fn test_json_sorted_keys() {
830 let rule = create_enabled_rule();
831 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833 let result = rule.check(&ctx).unwrap();
834
835 assert!(result.is_empty());
836 }
837
838 #[test]
839 fn test_json_unsorted_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 result = rule.check(&ctx).unwrap();
844
845 assert_eq!(result.len(), 1);
846 assert!(result[0].message.contains("JSON"));
847 assert!(result[0].message.contains("not sorted"));
848 }
849
850 #[test]
851 fn test_json_fix_sorts_keys() {
852 let rule = create_enabled_rule();
853 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855 let fixed = rule.fix(&ctx).unwrap();
856
857 let author_pos = fixed.find("author").unwrap();
859 let title_pos = fixed.find("title").unwrap();
860 assert!(author_pos < title_pos);
861 }
862
863 #[test]
864 fn test_json_always_fixable() {
865 let rule = create_enabled_rule();
866 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
869 let result = rule.check(&ctx).unwrap();
870
871 assert_eq!(result.len(), 1);
872 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
874 }
875
876 #[test]
879 fn test_empty_content() {
880 let rule = create_enabled_rule();
881 let content = "";
882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
883 let result = rule.check(&ctx).unwrap();
884
885 assert!(result.is_empty());
886 }
887
888 #[test]
889 fn test_empty_frontmatter() {
890 let rule = create_enabled_rule();
891 let content = "---\n---\n\n# Heading";
892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893 let result = rule.check(&ctx).unwrap();
894
895 assert!(result.is_empty());
896 }
897
898 #[test]
899 fn test_toml_nested_tables_ignored() {
900 let rule = create_enabled_rule();
902 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
903 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904 let result = rule.check(&ctx).unwrap();
905
906 assert_eq!(result.len(), 1);
908 assert!(result[0].message.contains("'sort_by' should come before 'title'"));
910 assert!(!result[0].message.contains("we_have_extra"));
911 }
912
913 #[test]
914 fn test_toml_nested_taxonomies_ignored() {
915 let rule = create_enabled_rule();
917 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919 let result = rule.check(&ctx).unwrap();
920
921 assert_eq!(result.len(), 1);
923 assert!(result[0].message.contains("'date' should come before 'title'"));
925 assert!(!result[0].message.contains("categories"));
926 assert!(!result[0].message.contains("tags"));
927 }
928
929 #[test]
932 fn test_yaml_unicode_keys() {
933 let rule = create_enabled_rule();
934 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\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_eq!(result.len(), 1);
941 }
942
943 #[test]
944 fn test_yaml_keys_with_special_characters() {
945 let rule = create_enabled_rule();
946 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
948 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949 let result = rule.check(&ctx).unwrap();
950
951 assert!(result.is_empty());
953 }
954
955 #[test]
956 fn test_yaml_keys_with_numbers() {
957 let rule = create_enabled_rule();
958 let content = "---\nkey1: value\nkey10: value\nkey2: value\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!(result.is_empty());
964 }
965
966 #[test]
967 fn test_yaml_multiline_string_block_literal() {
968 let rule = create_enabled_rule();
969 let content =
970 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\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 assert!(result[0].message.contains("'author' should come before 'title'"));
977 }
978
979 #[test]
980 fn test_yaml_multiline_string_folded() {
981 let rule = create_enabled_rule();
982 let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\n---\n\n# Heading";
983 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let result = rule.check(&ctx).unwrap();
985
986 assert_eq!(result.len(), 1);
988 }
989
990 #[test]
991 fn test_yaml_fix_preserves_multiline_values() {
992 let rule = create_enabled_rule();
993 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
994 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let fixed = rule.fix(&ctx).unwrap();
996
997 let desc_pos = fixed.find("description").unwrap();
999 let title_pos = fixed.find("title").unwrap();
1000 assert!(desc_pos < title_pos);
1001 }
1002
1003 #[test]
1004 fn test_yaml_quoted_keys() {
1005 let rule = create_enabled_rule();
1006 let content = "---\n\"quoted-key\": value1\nunquoted: value2\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!(result.is_empty());
1012 }
1013
1014 #[test]
1015 fn test_yaml_duplicate_keys() {
1016 let rule = create_enabled_rule();
1018 let content = "---\ntitle: First\nauthor: John\ntitle: Second\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_eq!(result.len(), 1);
1024 }
1025
1026 #[test]
1027 fn test_toml_inline_table() {
1028 let rule = create_enabled_rule();
1029 let content =
1030 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032 let result = rule.check(&ctx).unwrap();
1033
1034 assert!(result.is_empty());
1036 }
1037
1038 #[test]
1039 fn test_toml_array_of_tables() {
1040 let rule = create_enabled_rule();
1041 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1042 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1043 let result = rule.check(&ctx).unwrap();
1044
1045 assert_eq!(result.len(), 1);
1047 assert!(result[0].message.contains("'date' should come before 'title'"));
1049 }
1050
1051 #[test]
1052 fn test_json_nested_objects() {
1053 let rule = create_enabled_rule();
1054 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1055 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056 let result = rule.check(&ctx).unwrap();
1057
1058 assert!(result.is_empty());
1060 }
1061
1062 #[test]
1063 fn test_json_arrays() {
1064 let rule = create_enabled_rule();
1065 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067 let result = rule.check(&ctx).unwrap();
1068
1069 assert_eq!(result.len(), 1);
1071 }
1072
1073 #[test]
1074 fn test_fix_preserves_content_after_frontmatter() {
1075 let rule = create_enabled_rule();
1076 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1077 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078 let fixed = rule.fix(&ctx).unwrap();
1079
1080 assert!(fixed.contains("# Heading"));
1082 assert!(fixed.contains("Paragraph 1."));
1083 assert!(fixed.contains("- List item"));
1084 assert!(fixed.contains("- Another item"));
1085 }
1086
1087 #[test]
1088 fn test_fix_yaml_produces_valid_yaml() {
1089 let rule = create_enabled_rule();
1090 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1091 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1092 let fixed = rule.fix(&ctx).unwrap();
1093
1094 let lines: Vec<&str> = fixed.lines().collect();
1097 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1098 let fm_content: String = lines[1..fm_end].join("\n");
1099
1100 let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&fm_content);
1102 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1103 }
1104
1105 #[test]
1106 fn test_fix_toml_produces_valid_toml() {
1107 let rule = create_enabled_rule();
1108 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1109 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110 let fixed = rule.fix(&ctx).unwrap();
1111
1112 let lines: Vec<&str> = fixed.lines().collect();
1114 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1115 let fm_content: String = lines[1..fm_end].join("\n");
1116
1117 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1119 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1120 }
1121
1122 #[test]
1123 fn test_fix_json_produces_valid_json() {
1124 let rule = create_enabled_rule();
1125 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1126 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1127 let fixed = rule.fix(&ctx).unwrap();
1128
1129 let json_end = fixed.find("\n\n").unwrap();
1131 let json_content = &fixed[..json_end];
1132
1133 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1135 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1136 }
1137
1138 #[test]
1139 fn test_many_keys_performance() {
1140 let rule = create_enabled_rule();
1141 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1143 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1145
1146 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1147 let result = rule.check(&ctx).unwrap();
1148
1149 assert_eq!(result.len(), 1);
1151 }
1152
1153 #[test]
1154 fn test_yaml_empty_value() {
1155 let rule = create_enabled_rule();
1156 let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1157 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158 let result = rule.check(&ctx).unwrap();
1159
1160 assert_eq!(result.len(), 1);
1162 }
1163
1164 #[test]
1165 fn test_yaml_null_value() {
1166 let rule = create_enabled_rule();
1167 let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1168 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1169 let result = rule.check(&ctx).unwrap();
1170
1171 assert_eq!(result.len(), 1);
1172 }
1173
1174 #[test]
1175 fn test_yaml_boolean_values() {
1176 let rule = create_enabled_rule();
1177 let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1179 let result = rule.check(&ctx).unwrap();
1180
1181 assert_eq!(result.len(), 1);
1183 }
1184
1185 #[test]
1186 fn test_toml_boolean_values() {
1187 let rule = create_enabled_rule();
1188 let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1189 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1190 let result = rule.check(&ctx).unwrap();
1191
1192 assert_eq!(result.len(), 1);
1193 }
1194
1195 #[test]
1196 fn test_yaml_list_at_top_level() {
1197 let rule = create_enabled_rule();
1198 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200 let result = rule.check(&ctx).unwrap();
1201
1202 assert_eq!(result.len(), 1);
1204 }
1205
1206 #[test]
1207 fn test_three_keys_all_orderings() {
1208 let rule = create_enabled_rule();
1209
1210 let orderings = [
1212 ("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), ];
1219
1220 for (name, content, should_pass) in orderings {
1221 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222 let result = rule.check(&ctx).unwrap();
1223 assert_eq!(
1224 result.is_empty(),
1225 should_pass,
1226 "Ordering {name} should {} pass",
1227 if should_pass { "" } else { "not" }
1228 );
1229 }
1230 }
1231
1232 #[test]
1233 fn test_crlf_line_endings() {
1234 let rule = create_enabled_rule();
1235 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\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]
1244 fn test_json_escaped_quotes_in_keys() {
1245 let rule = create_enabled_rule();
1246 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1248 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249 let result = rule.check(&ctx).unwrap();
1250
1251 assert_eq!(result.len(), 1);
1253 }
1254
1255 #[test]
1258 fn test_warning_fix_yaml_sorts_keys() {
1259 let rule = create_enabled_rule();
1260 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1261 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1262 let warnings = rule.check(&ctx).unwrap();
1263
1264 assert_eq!(warnings.len(), 1);
1265 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1266
1267 let fix = warnings[0].fix.as_ref().unwrap();
1268 assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1269
1270 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1272
1273 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1275 let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1276 assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1277 }
1278
1279 #[test]
1280 fn test_warning_fix_preserves_yaml_list_indentation() {
1281 let rule = create_enabled_rule();
1282 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1283 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1284 let warnings = rule.check(&ctx).unwrap();
1285
1286 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1287
1288 assert!(
1290 fixed.contains(" - hello"),
1291 "List indentation should be preserved: {fixed}"
1292 );
1293 assert!(
1294 fixed.contains(" - world"),
1295 "List indentation should be preserved: {fixed}"
1296 );
1297 }
1298
1299 #[test]
1300 fn test_warning_fix_preserves_nested_object_indentation() {
1301 let rule = create_enabled_rule();
1302 let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
1303 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1304 let warnings = rule.check(&ctx).unwrap();
1305
1306 assert_eq!(warnings.len(), 1);
1307 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1308
1309 let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1311 let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1312 assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1313
1314 assert!(
1316 fixed.contains(" nested_key: nested_value"),
1317 "Nested object indentation should be preserved: {fixed}"
1318 );
1319 assert!(
1320 fixed.contains(" another: 123"),
1321 "Nested object indentation should be preserved: {fixed}"
1322 );
1323 }
1324
1325 #[test]
1326 fn test_warning_fix_preserves_deeply_nested_structure() {
1327 let rule = create_enabled_rule();
1328 let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
1329 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1330 let warnings = rule.check(&ctx).unwrap();
1331
1332 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1333
1334 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1336 let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1337 assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1338
1339 assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
1341 assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
1342 assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
1343 assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
1344 }
1345
1346 #[test]
1347 fn test_warning_fix_toml_sorts_keys() {
1348 let rule = create_enabled_rule();
1349 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1350 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351 let warnings = rule.check(&ctx).unwrap();
1352
1353 assert_eq!(warnings.len(), 1);
1354 assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1355
1356 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1357
1358 let author_pos = fixed.find("author").expect("author should exist");
1360 let title_pos = fixed.find("title").expect("title should exist");
1361 assert!(author_pos < title_pos, "author should come before title");
1362 }
1363
1364 #[test]
1365 fn test_warning_fix_json_sorts_keys() {
1366 let rule = create_enabled_rule();
1367 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1368 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369 let warnings = rule.check(&ctx).unwrap();
1370
1371 assert_eq!(warnings.len(), 1);
1372 assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1373
1374 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1375
1376 let author_pos = fixed.find("author").expect("author should exist");
1378 let title_pos = fixed.find("title").expect("title should exist");
1379 assert!(author_pos < title_pos, "author should come before title");
1380 }
1381
1382 #[test]
1383 fn test_warning_fix_no_fix_when_comments_present() {
1384 let rule = create_enabled_rule();
1385 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387 let warnings = rule.check(&ctx).unwrap();
1388
1389 assert_eq!(warnings.len(), 1);
1390 assert!(
1391 warnings[0].fix.is_none(),
1392 "Warning should NOT have a fix when comments are present"
1393 );
1394 assert!(
1395 warnings[0].message.contains("auto-fix unavailable"),
1396 "Message should indicate auto-fix is unavailable"
1397 );
1398 }
1399
1400 #[test]
1401 fn test_warning_fix_preserves_content_after_frontmatter() {
1402 let rule = create_enabled_rule();
1403 let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1405 let warnings = rule.check(&ctx).unwrap();
1406
1407 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1408
1409 assert!(fixed.contains("# Heading"), "Heading should be preserved");
1411 assert!(
1412 fixed.contains("Paragraph with content."),
1413 "Paragraph should be preserved"
1414 );
1415 assert!(fixed.contains("- List item"), "List item should be preserved");
1416 }
1417
1418 #[test]
1419 fn test_warning_fix_idempotent() {
1420 let rule = create_enabled_rule();
1421 let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423 let warnings = rule.check(&ctx).unwrap();
1424
1425 let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1426
1427 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1429 let warnings2 = rule.check(&ctx2).unwrap();
1430
1431 assert!(
1432 warnings2.is_empty(),
1433 "After fixing, no more warnings should be produced"
1434 );
1435 }
1436
1437 #[test]
1438 fn test_warning_fix_preserves_multiline_block_literal() {
1439 let rule = create_enabled_rule();
1440 let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
1441 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1442 let warnings = rule.check(&ctx).unwrap();
1443
1444 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1445
1446 assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1448 assert!(
1449 fixed.contains(" Line 1 of block"),
1450 "Block literal line 1 should be preserved with indent"
1451 );
1452 assert!(
1453 fixed.contains(" Line 2 of block"),
1454 "Block literal line 2 should be preserved with indent"
1455 );
1456 }
1457
1458 #[test]
1459 fn test_warning_fix_preserves_folded_string() {
1460 let rule = create_enabled_rule();
1461 let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\n";
1462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463 let warnings = rule.check(&ctx).unwrap();
1464
1465 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1466
1467 assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1469 assert!(
1470 fixed.contains(" Folded line 1"),
1471 "Folded line 1 should be preserved with indent"
1472 );
1473 assert!(
1474 fixed.contains(" Folded line 2"),
1475 "Folded line 2 should be preserved with indent"
1476 );
1477 }
1478
1479 #[test]
1480 fn test_warning_fix_preserves_4_space_indentation() {
1481 let rule = create_enabled_rule();
1482 let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
1484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1485 let warnings = rule.check(&ctx).unwrap();
1486
1487 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1488
1489 assert!(
1491 fixed.contains(" nested: with_4_spaces"),
1492 "4-space indentation should be preserved: {fixed}"
1493 );
1494 assert!(
1495 fixed.contains(" another: value"),
1496 "4-space indentation should be preserved: {fixed}"
1497 );
1498 }
1499
1500 #[test]
1501 fn test_warning_fix_preserves_tab_indentation() {
1502 let rule = create_enabled_rule();
1503 let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1505 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1506 let warnings = rule.check(&ctx).unwrap();
1507
1508 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1509
1510 assert!(
1512 fixed.contains("\tnested: with_tab"),
1513 "Tab indentation should be preserved: {fixed}"
1514 );
1515 assert!(
1516 fixed.contains("\tanother: value"),
1517 "Tab indentation should be preserved: {fixed}"
1518 );
1519 }
1520
1521 #[test]
1522 fn test_warning_fix_preserves_inline_list() {
1523 let rule = create_enabled_rule();
1524 let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1527 let warnings = rule.check(&ctx).unwrap();
1528
1529 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1530
1531 assert!(
1533 fixed.contains("aaa: [one, two, three]"),
1534 "Inline list should be preserved exactly: {fixed}"
1535 );
1536 }
1537
1538 #[test]
1539 fn test_warning_fix_preserves_quoted_strings() {
1540 let rule = create_enabled_rule();
1541 let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1543 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1544 let warnings = rule.check(&ctx).unwrap();
1545
1546 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1547
1548 assert!(
1550 fixed.contains("aaa: \"value with: colon\""),
1551 "Double-quoted string should be preserved: {fixed}"
1552 );
1553 assert!(
1554 fixed.contains("bbb: 'single quotes'"),
1555 "Single-quoted string should be preserved: {fixed}"
1556 );
1557 }
1558
1559 #[test]
1562 fn test_yaml_custom_key_order_sorted() {
1563 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1565 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\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!(result.is_empty());
1571 }
1572
1573 #[test]
1574 fn test_yaml_custom_key_order_unsorted() {
1575 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1577 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1578 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1579 let result = rule.check(&ctx).unwrap();
1580
1581 assert_eq!(result.len(), 1);
1582 assert!(result[0].message.contains("'date' should come before 'author'"));
1584 }
1585
1586 #[test]
1587 fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1588 let rule = create_rule_with_key_order(vec!["title"]);
1590 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1591 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1592 let result = rule.check(&ctx).unwrap();
1593
1594 assert!(result.is_empty());
1597 }
1598
1599 #[test]
1600 fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1601 let rule = create_rule_with_key_order(vec!["title"]);
1603 let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1604 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1605 let result = rule.check(&ctx).unwrap();
1606
1607 assert_eq!(result.len(), 1);
1609 assert!(result[0].message.contains("'author' should come before 'zebra'"));
1610 }
1611
1612 #[test]
1613 fn test_yaml_custom_key_order_fix() {
1614 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1615 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1616 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1617 let fixed = rule.fix(&ctx).unwrap();
1618
1619 let title_pos = fixed.find("title:").unwrap();
1621 let date_pos = fixed.find("date:").unwrap();
1622 let author_pos = fixed.find("author:").unwrap();
1623 assert!(
1624 title_pos < date_pos && date_pos < author_pos,
1625 "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1626 );
1627 }
1628
1629 #[test]
1630 fn test_yaml_custom_key_order_fix_with_unlisted() {
1631 let rule = create_rule_with_key_order(vec!["title", "author"]);
1633 let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1634 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1635 let fixed = rule.fix(&ctx).unwrap();
1636
1637 let title_pos = fixed.find("title:").unwrap();
1639 let author_pos = fixed.find("author:").unwrap();
1640 let aardvark_pos = fixed.find("aardvark:").unwrap();
1641 let zebra_pos = fixed.find("zebra:").unwrap();
1642
1643 assert!(
1644 title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1645 "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1646 );
1647 }
1648
1649 #[test]
1650 fn test_toml_custom_key_order_sorted() {
1651 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1652 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1653 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654 let result = rule.check(&ctx).unwrap();
1655
1656 assert!(result.is_empty());
1657 }
1658
1659 #[test]
1660 fn test_toml_custom_key_order_unsorted() {
1661 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1662 let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1663 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1664 let result = rule.check(&ctx).unwrap();
1665
1666 assert_eq!(result.len(), 1);
1667 assert!(result[0].message.contains("TOML"));
1668 }
1669
1670 #[test]
1671 fn test_json_custom_key_order_sorted() {
1672 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1673 let content = "{\n \"title\": \"Test\",\n \"date\": \"2024-01-01\",\n \"author\": \"John\"\n}\n\n# Heading";
1674 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1675 let result = rule.check(&ctx).unwrap();
1676
1677 assert!(result.is_empty());
1678 }
1679
1680 #[test]
1681 fn test_json_custom_key_order_unsorted() {
1682 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1683 let content = "{\n \"author\": \"John\",\n \"title\": \"Test\",\n \"date\": \"2024-01-01\"\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_eq!(result.len(), 1);
1688 assert!(result[0].message.contains("JSON"));
1689 }
1690
1691 #[test]
1692 fn test_key_order_case_insensitive_match() {
1693 let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
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!(result.is_empty());
1701 }
1702
1703 #[test]
1704 fn test_key_order_partial_match() {
1705 let rule = create_rule_with_key_order(vec!["title"]);
1707 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1708 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1709 let result = rule.check(&ctx).unwrap();
1710
1711 assert_eq!(result.len(), 1);
1722 assert!(result[0].message.contains("'author' should come before 'date'"));
1723 }
1724
1725 #[test]
1728 fn test_key_order_empty_array_falls_back_to_alphabetical() {
1729 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1731 enabled: true,
1732 key_order: Some(vec![]),
1733 });
1734 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1736 let result = rule.check(&ctx).unwrap();
1737
1738 assert_eq!(result.len(), 1);
1741 assert!(result[0].message.contains("'author' should come before 'title'"));
1742 }
1743
1744 #[test]
1745 fn test_key_order_single_key() {
1746 let rule = create_rule_with_key_order(vec!["title"]);
1748 let content = "---\ntitle: Test\n---\n\n# Heading";
1749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1750 let result = rule.check(&ctx).unwrap();
1751
1752 assert!(result.is_empty());
1753 }
1754
1755 #[test]
1756 fn test_key_order_all_keys_specified() {
1757 let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
1759 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1760 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1761 let result = rule.check(&ctx).unwrap();
1762
1763 assert!(result.is_empty());
1764 }
1765
1766 #[test]
1767 fn test_key_order_no_keys_match() {
1768 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1770 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1771 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1772 let result = rule.check(&ctx).unwrap();
1773
1774 assert!(result.is_empty());
1777 }
1778
1779 #[test]
1780 fn test_key_order_no_keys_match_unsorted() {
1781 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1783 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1785 let result = rule.check(&ctx).unwrap();
1786
1787 assert_eq!(result.len(), 1);
1790 }
1791
1792 #[test]
1793 fn test_key_order_duplicate_keys_in_config() {
1794 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1796 enabled: true,
1797 key_order: Some(vec![
1798 "title".to_string(),
1799 "author".to_string(),
1800 "title".to_string(), ]),
1802 });
1803 let content = "---\ntitle: Test\nauthor: John\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!(result.is_empty());
1809 }
1810
1811 #[test]
1812 fn test_key_order_with_comments_still_skips_fix() {
1813 let rule = create_rule_with_key_order(vec!["title", "author"]);
1815 let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
1816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817 let result = rule.check(&ctx).unwrap();
1818
1819 assert_eq!(result.len(), 1);
1821 assert!(result[0].message.contains("auto-fix unavailable"));
1822 assert!(result[0].fix.is_none());
1823 }
1824
1825 #[test]
1826 fn test_toml_custom_key_order_fix() {
1827 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1828 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1830 let fixed = rule.fix(&ctx).unwrap();
1831
1832 let title_pos = fixed.find("title").unwrap();
1834 let date_pos = fixed.find("date").unwrap();
1835 let author_pos = fixed.find("author").unwrap();
1836 assert!(
1837 title_pos < date_pos && date_pos < author_pos,
1838 "Fixed TOML should have keys in custom order. Got:\n{fixed}"
1839 );
1840 }
1841
1842 #[test]
1843 fn test_json_custom_key_order_fix() {
1844 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1845 let content = "{\n \"author\": \"John\",\n \"date\": \"2024-01-01\",\n \"title\": \"Test\"\n}\n\n# Heading";
1846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1847 let fixed = rule.fix(&ctx).unwrap();
1848
1849 let title_pos = fixed.find("\"title\"").unwrap();
1851 let date_pos = fixed.find("\"date\"").unwrap();
1852 let author_pos = fixed.find("\"author\"").unwrap();
1853 assert!(
1854 title_pos < date_pos && date_pos < author_pos,
1855 "Fixed JSON should have keys in custom order. Got:\n{fixed}"
1856 );
1857 }
1858
1859 #[test]
1860 fn test_key_order_unicode_keys() {
1861 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1863 enabled: true,
1864 key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
1865 });
1866 let content = "---\nタイトル: テスト\n著者: 山田太郎\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());
1872 }
1873
1874 #[test]
1875 fn test_key_order_mixed_specified_and_unlisted_boundary() {
1876 let rule = create_rule_with_key_order(vec!["z_last_specified"]);
1878 let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
1879 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1880 let result = rule.check(&ctx).unwrap();
1881
1882 assert!(result.is_empty());
1885 }
1886
1887 #[test]
1888 fn test_key_order_fix_preserves_values() {
1889 let rule = create_rule_with_key_order(vec!["title", "tags"]);
1891 let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n\n# Heading";
1892 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893 let fixed = rule.fix(&ctx).unwrap();
1894
1895 let title_pos = fixed.find("title:").unwrap();
1897 let tags_pos = fixed.find("tags:").unwrap();
1898 assert!(title_pos < tags_pos, "title should come before tags");
1899
1900 assert!(fixed.contains("- rust"), "List items should be preserved");
1902 assert!(fixed.contains("- markdown"), "List items should be preserved");
1903 }
1904
1905 #[test]
1906 fn test_key_order_idempotent_fix() {
1907 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1909 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1910 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1911
1912 let fixed_once = rule.fix(&ctx).unwrap();
1913 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1914 let fixed_twice = rule.fix(&ctx2).unwrap();
1915
1916 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1917 }
1918
1919 #[test]
1920 fn test_key_order_respects_later_position_over_alphabetical() {
1921 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
1923 let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
1924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1925 let result = rule.check(&ctx).unwrap();
1926
1927 assert!(result.is_empty());
1929 }
1930
1931 #[test]
1934 fn test_json_braces_in_string_values_extracts_all_keys() {
1935 let rule = create_enabled_rule();
1939 let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
1940 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1941 let result = rule.check(&ctx).unwrap();
1942
1943 assert!(
1945 result.is_empty(),
1946 "All keys should be extracted and recognized as sorted. Got: {result:?}"
1947 );
1948 }
1949
1950 #[test]
1951 fn test_json_braces_in_string_key_after_brace_value_detected() {
1952 let rule = create_enabled_rule();
1954 let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
1957 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1958 let result = rule.check(&ctx).unwrap();
1959
1960 assert_eq!(
1963 result.len(),
1964 1,
1965 "Should detect unsorted keys after brace-containing string value"
1966 );
1967 assert!(
1968 result[0].message.contains("'author' should come before 'description'"),
1969 "Should report author before description. Got: {}",
1970 result[0].message
1971 );
1972 }
1973
1974 #[test]
1975 fn test_json_brackets_in_string_values() {
1976 let rule = create_enabled_rule();
1978 let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
1979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1980 let result = rule.check(&ctx).unwrap();
1981
1982 assert_eq!(
1984 result.len(),
1985 1,
1986 "Should detect unsorted keys despite brackets in string values"
1987 );
1988 assert!(
1989 result[0].message.contains("'author' should come before 'description'"),
1990 "Got: {}",
1991 result[0].message
1992 );
1993 }
1994
1995 #[test]
1996 fn test_json_escaped_quotes_in_values() {
1997 let rule = create_enabled_rule();
1999 let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2000 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2001 let result = rule.check(&ctx).unwrap();
2002
2003 assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
2005 assert!(
2006 result[0].message.contains("'author' should come before 'title'"),
2007 "Got: {}",
2008 result[0].message
2009 );
2010 }
2011
2012 #[test]
2013 fn test_json_multiple_braces_in_string() {
2014 let rule = create_enabled_rule();
2016 let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2017 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2018 let result = rule.check(&ctx).unwrap();
2019
2020 assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
2022 assert!(
2023 result[0].message.contains("'author' should come before 'pattern'"),
2024 "Got: {}",
2025 result[0].message
2026 );
2027 }
2028
2029 #[test]
2030 fn test_key_order_detects_wrong_custom_order() {
2031 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2033 let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
2034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2035 let result = rule.check(&ctx).unwrap();
2036
2037 assert_eq!(result.len(), 1);
2038 assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
2039 }
2040}