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 match self.fix_yaml(content) {
273 Ok(fixed_content) if fixed_content != content => Some(Fix {
274 range: 0..content.len(),
275 replacement: fixed_content,
276 }),
277 _ => None,
278 }
279 };
280
281 let message = if has_comments {
282 format!(
283 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
284 )
285 } else {
286 format!(
287 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
288 )
289 };
290
291 warnings.push(LintWarning {
292 rule_name: Some(self.name().to_string()),
293 message,
294 line: key_line,
295 column: 1,
296 end_line: key_line,
297 end_column: out_of_place.len() + 1,
298 severity: Severity::Warning,
299 fix,
300 });
301 }
302 FrontMatterType::Toml => {
303 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
304 if frontmatter_lines.is_empty() {
305 return Ok(warnings);
306 }
307
308 let keys = Self::extract_toml_keys(&frontmatter_lines);
309 let key_order = self.config.key_order.as_deref();
310 let Some((key_idx, out_of_place, should_come_after)) =
311 Self::find_first_unsorted_indexed_pair(&keys, key_order)
312 else {
313 return Ok(warnings);
314 };
315 let key_line = key_idx + 2;
316
317 let has_comments = Self::has_comments(&frontmatter_lines);
318
319 let fix = if has_comments {
320 None
321 } else {
322 match self.fix_toml(content) {
324 Ok(fixed_content) if fixed_content != content => Some(Fix {
325 range: 0..content.len(),
326 replacement: fixed_content,
327 }),
328 _ => None,
329 }
330 };
331
332 let message = if has_comments {
333 format!(
334 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
335 )
336 } else {
337 format!(
338 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
339 )
340 };
341
342 warnings.push(LintWarning {
343 rule_name: Some(self.name().to_string()),
344 message,
345 line: key_line,
346 column: 1,
347 end_line: key_line,
348 end_column: out_of_place.len() + 1,
349 severity: Severity::Warning,
350 fix,
351 });
352 }
353 FrontMatterType::Json => {
354 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
355 if frontmatter_lines.is_empty() {
356 return Ok(warnings);
357 }
358
359 let keys = Self::extract_json_keys(&frontmatter_lines);
360 let key_order = self.config.key_order.as_deref();
361 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_pair(&keys, key_order) else {
362 return Ok(warnings);
363 };
364
365 let fix = match self.fix_json(content) {
367 Ok(fixed_content) if fixed_content != content => Some(Fix {
368 range: 0..content.len(),
369 replacement: fixed_content,
370 }),
371 _ => None,
372 };
373
374 let message = format!(
375 "JSON frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
376 );
377
378 warnings.push(LintWarning {
379 rule_name: Some(self.name().to_string()),
380 message,
381 line: 2,
382 column: 1,
383 end_line: 2,
384 end_column: out_of_place.len() + 1,
385 severity: Severity::Warning,
386 fix,
387 });
388 }
389 _ => {
390 }
392 }
393
394 Ok(warnings)
395 }
396
397 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
398 let content = ctx.content;
399
400 if ctx.is_rule_disabled(self.name(), 2) {
402 return Ok(content.to_string());
403 }
404
405 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
406
407 match fm_type {
408 FrontMatterType::Yaml => self.fix_yaml(content),
409 FrontMatterType::Toml => self.fix_toml(content),
410 FrontMatterType::Json => self.fix_json(content),
411 _ => Ok(content.to_string()),
412 }
413 }
414
415 fn category(&self) -> RuleCategory {
416 RuleCategory::FrontMatter
417 }
418
419 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
420 ctx.content.is_empty()
421 || !ctx.content.starts_with("---") && !ctx.content.starts_with("+++") && !ctx.content.starts_with('{')
422 }
423
424 fn as_any(&self) -> &dyn std::any::Any {
425 self
426 }
427
428 fn default_config_section(&self) -> Option<(String, toml::Value)> {
429 let table = crate::rule_config_serde::config_schema_table(&MD072Config::default())?;
430 Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
431 }
432
433 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
434 where
435 Self: Sized,
436 {
437 let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
438 Box::new(Self::from_config_struct(rule_config))
439 }
440}
441
442impl MD072FrontmatterKeySort {
443 fn fix_yaml(&self, content: &str) -> Result<String, LintError> {
444 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
445 if frontmatter_lines.is_empty() {
446 return Ok(content.to_string());
447 }
448
449 if Self::has_comments(&frontmatter_lines) {
451 return Ok(content.to_string());
452 }
453
454 let keys = Self::extract_yaml_keys(&frontmatter_lines);
455 let key_order = self.config.key_order.as_deref();
456 if Self::are_indexed_keys_sorted(&keys, key_order) {
457 return Ok(content.to_string());
458 }
459
460 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
463
464 for (i, (line_idx, key)) in keys.iter().enumerate() {
465 let start = *line_idx;
466 let end = if i + 1 < keys.len() {
467 keys[i + 1].0
468 } else {
469 frontmatter_lines.len()
470 };
471
472 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
473 key_blocks.push((key.clone(), block_lines));
474 }
475
476 Self::sort_keys_by_order(&mut key_blocks, key_order);
478
479 let content_lines: Vec<&str> = content.lines().collect();
481 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
482
483 let mut result = String::new();
484 result.push_str("---\n");
485 for (_, lines) in &key_blocks {
486 for line in lines {
487 result.push_str(line);
488 result.push('\n');
489 }
490 }
491 result.push_str("---");
492
493 if fm_end < content_lines.len() {
494 result.push('\n');
495 result.push_str(&content_lines[fm_end..].join("\n"));
496 }
497
498 Ok(result)
499 }
500
501 fn fix_toml(&self, content: &str) -> Result<String, LintError> {
502 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
503 if frontmatter_lines.is_empty() {
504 return Ok(content.to_string());
505 }
506
507 if Self::has_comments(&frontmatter_lines) {
509 return Ok(content.to_string());
510 }
511
512 let keys = Self::extract_toml_keys(&frontmatter_lines);
513 let key_order = self.config.key_order.as_deref();
514 if Self::are_indexed_keys_sorted(&keys, key_order) {
515 return Ok(content.to_string());
516 }
517
518 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
521
522 for (i, (line_idx, key)) in keys.iter().enumerate() {
523 let start = *line_idx;
524 let end = if i + 1 < keys.len() {
525 keys[i + 1].0
526 } else {
527 frontmatter_lines.len()
528 };
529
530 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
531 key_blocks.push((key.clone(), block_lines));
532 }
533
534 Self::sort_keys_by_order(&mut key_blocks, key_order);
536
537 let content_lines: Vec<&str> = content.lines().collect();
539 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
540
541 let mut result = String::new();
542 result.push_str("+++\n");
543 for (_, lines) in &key_blocks {
544 for line in lines {
545 result.push_str(line);
546 result.push('\n');
547 }
548 }
549 result.push_str("+++");
550
551 if fm_end < content_lines.len() {
552 result.push('\n');
553 result.push_str(&content_lines[fm_end..].join("\n"));
554 }
555
556 Ok(result)
557 }
558
559 fn fix_json(&self, content: &str) -> Result<String, LintError> {
560 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
561 if frontmatter_lines.is_empty() {
562 return Ok(content.to_string());
563 }
564
565 let keys = Self::extract_json_keys(&frontmatter_lines);
566 let key_order = self.config.key_order.as_deref();
567
568 if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
569 return Ok(content.to_string());
570 }
571
572 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
574
575 match serde_json::from_str::<serde_json::Value>(&json_content) {
577 Ok(serde_json::Value::Object(map)) => {
578 let mut sorted_map = serde_json::Map::new();
580 let mut keys: Vec<_> = map.keys().cloned().collect();
581 keys.sort_by(|a, b| {
582 let pos_a = Self::key_sort_position(a, key_order);
583 let pos_b = Self::key_sort_position(b, key_order);
584 pos_a.cmp(&pos_b)
585 });
586
587 for key in keys {
588 if let Some(value) = map.get(&key) {
589 sorted_map.insert(key, value.clone());
590 }
591 }
592
593 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
594 Ok(sorted_json) => {
595 let lines: Vec<&str> = content.lines().collect();
596 let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
597
598 let mut result = String::new();
601 result.push_str(&sorted_json);
602
603 if fm_end < lines.len() {
604 result.push('\n');
605 result.push_str(&lines[fm_end..].join("\n"));
606 }
607
608 Ok(result)
609 }
610 Err(_) => Ok(content.to_string()),
611 }
612 }
613 _ => Ok(content.to_string()),
614 }
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use crate::lint_context::LintContext;
622
623 fn create_enabled_rule() -> MD072FrontmatterKeySort {
625 MD072FrontmatterKeySort::from_config_struct(MD072Config {
626 enabled: true,
627 key_order: None,
628 })
629 }
630
631 fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
633 MD072FrontmatterKeySort::from_config_struct(MD072Config {
634 enabled: true,
635 key_order: Some(keys.into_iter().map(String::from).collect()),
636 })
637 }
638
639 #[test]
642 fn test_enabled_via_config() {
643 let rule = create_enabled_rule();
644 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
645 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
646 let result = rule.check(&ctx).unwrap();
647
648 assert_eq!(result.len(), 1);
650 }
651
652 #[test]
655 fn test_no_frontmatter() {
656 let rule = create_enabled_rule();
657 let content = "# Heading\n\nContent.";
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_sorted_keys() {
666 let rule = create_enabled_rule();
667 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\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!(result.is_empty());
672 }
673
674 #[test]
675 fn test_yaml_unsorted_keys() {
676 let rule = create_enabled_rule();
677 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
678 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
679 let result = rule.check(&ctx).unwrap();
680
681 assert_eq!(result.len(), 1);
682 assert!(result[0].message.contains("YAML"));
683 assert!(result[0].message.contains("not sorted"));
684 assert!(result[0].message.contains("'author' should come before 'title'"));
686 }
687
688 #[test]
689 fn test_yaml_case_insensitive_sort() {
690 let rule = create_enabled_rule();
691 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693 let result = rule.check(&ctx).unwrap();
694
695 assert!(result.is_empty());
697 }
698
699 #[test]
700 fn test_yaml_fix_sorts_keys() {
701 let rule = create_enabled_rule();
702 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704 let fixed = rule.fix(&ctx).unwrap();
705
706 let author_pos = fixed.find("author:").unwrap();
708 let title_pos = fixed.find("title:").unwrap();
709 assert!(author_pos < title_pos);
710 }
711
712 #[test]
713 fn test_yaml_no_fix_with_comments() {
714 let rule = create_enabled_rule();
715 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717 let result = rule.check(&ctx).unwrap();
718
719 assert_eq!(result.len(), 1);
720 assert!(result[0].message.contains("auto-fix unavailable"));
721 assert!(result[0].fix.is_none());
722
723 let fixed = rule.fix(&ctx).unwrap();
725 assert_eq!(fixed, content);
726 }
727
728 #[test]
729 fn test_yaml_single_key() {
730 let rule = create_enabled_rule();
731 let content = "---\ntitle: Test\n---\n\n# Heading";
732 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
733 let result = rule.check(&ctx).unwrap();
734
735 assert!(result.is_empty());
737 }
738
739 #[test]
740 fn test_yaml_nested_keys_ignored() {
741 let rule = create_enabled_rule();
742 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
744 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
745 let result = rule.check(&ctx).unwrap();
746
747 assert!(result.is_empty());
749 }
750
751 #[test]
752 fn test_yaml_fix_idempotent() {
753 let rule = create_enabled_rule();
754 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let fixed_once = rule.fix(&ctx).unwrap();
757
758 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
759 let fixed_twice = rule.fix(&ctx2).unwrap();
760
761 assert_eq!(fixed_once, fixed_twice);
762 }
763
764 #[test]
765 fn test_yaml_complex_values() {
766 let rule = create_enabled_rule();
767 let content =
769 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
770 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
771 let result = rule.check(&ctx).unwrap();
772
773 assert!(result.is_empty());
775 }
776
777 #[test]
780 fn test_toml_sorted_keys() {
781 let rule = create_enabled_rule();
782 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\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!(result.is_empty());
787 }
788
789 #[test]
790 fn test_toml_unsorted_keys() {
791 let rule = create_enabled_rule();
792 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
793 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794 let result = rule.check(&ctx).unwrap();
795
796 assert_eq!(result.len(), 1);
797 assert!(result[0].message.contains("TOML"));
798 assert!(result[0].message.contains("not sorted"));
799 }
800
801 #[test]
802 fn test_toml_fix_sorts_keys() {
803 let rule = create_enabled_rule();
804 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
805 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
806 let fixed = rule.fix(&ctx).unwrap();
807
808 let author_pos = fixed.find("author").unwrap();
810 let title_pos = fixed.find("title").unwrap();
811 assert!(author_pos < title_pos);
812 }
813
814 #[test]
815 fn test_toml_no_fix_with_comments() {
816 let rule = create_enabled_rule();
817 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
818 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
819 let result = rule.check(&ctx).unwrap();
820
821 assert_eq!(result.len(), 1);
822 assert!(result[0].message.contains("auto-fix unavailable"));
823
824 let fixed = rule.fix(&ctx).unwrap();
826 assert_eq!(fixed, content);
827 }
828
829 #[test]
832 fn test_json_sorted_keys() {
833 let rule = create_enabled_rule();
834 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\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!(result.is_empty());
839 }
840
841 #[test]
842 fn test_json_unsorted_keys() {
843 let rule = create_enabled_rule();
844 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
845 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
846 let result = rule.check(&ctx).unwrap();
847
848 assert_eq!(result.len(), 1);
849 assert!(result[0].message.contains("JSON"));
850 assert!(result[0].message.contains("not sorted"));
851 }
852
853 #[test]
854 fn test_json_fix_sorts_keys() {
855 let rule = create_enabled_rule();
856 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
857 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
858 let fixed = rule.fix(&ctx).unwrap();
859
860 let author_pos = fixed.find("author").unwrap();
862 let title_pos = fixed.find("title").unwrap();
863 assert!(author_pos < title_pos);
864 }
865
866 #[test]
867 fn test_json_always_fixable() {
868 let rule = create_enabled_rule();
869 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
871 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
872 let result = rule.check(&ctx).unwrap();
873
874 assert_eq!(result.len(), 1);
875 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
877 }
878
879 #[test]
882 fn test_empty_content() {
883 let rule = create_enabled_rule();
884 let content = "";
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_empty_frontmatter() {
893 let rule = create_enabled_rule();
894 let content = "---\n---\n\n# Heading";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let result = rule.check(&ctx).unwrap();
897
898 assert!(result.is_empty());
899 }
900
901 #[test]
902 fn test_toml_nested_tables_ignored() {
903 let rule = create_enabled_rule();
905 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907 let result = rule.check(&ctx).unwrap();
908
909 assert_eq!(result.len(), 1);
911 assert!(result[0].message.contains("'sort_by' should come before 'title'"));
913 assert!(!result[0].message.contains("we_have_extra"));
914 }
915
916 #[test]
917 fn test_toml_nested_taxonomies_ignored() {
918 let rule = create_enabled_rule();
920 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
921 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
922 let result = rule.check(&ctx).unwrap();
923
924 assert_eq!(result.len(), 1);
926 assert!(result[0].message.contains("'date' should come before 'title'"));
928 assert!(!result[0].message.contains("categories"));
929 assert!(!result[0].message.contains("tags"));
930 }
931
932 #[test]
935 fn test_yaml_unicode_keys() {
936 let rule = create_enabled_rule();
937 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
939 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
940 let result = rule.check(&ctx).unwrap();
941
942 assert_eq!(result.len(), 1);
944 }
945
946 #[test]
947 fn test_yaml_keys_with_special_characters() {
948 let rule = create_enabled_rule();
949 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
951 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
952 let result = rule.check(&ctx).unwrap();
953
954 assert!(result.is_empty());
956 }
957
958 #[test]
959 fn test_yaml_keys_with_numbers() {
960 let rule = create_enabled_rule();
961 let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
962 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
963 let result = rule.check(&ctx).unwrap();
964
965 assert!(result.is_empty());
967 }
968
969 #[test]
970 fn test_yaml_multiline_string_block_literal() {
971 let rule = create_enabled_rule();
972 let content =
973 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
975 let result = rule.check(&ctx).unwrap();
976
977 assert_eq!(result.len(), 1);
979 assert!(result[0].message.contains("'author' should come before 'title'"));
980 }
981
982 #[test]
983 fn test_yaml_multiline_string_folded() {
984 let rule = create_enabled_rule();
985 let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\n---\n\n# Heading";
986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
987 let result = rule.check(&ctx).unwrap();
988
989 assert_eq!(result.len(), 1);
991 }
992
993 #[test]
994 fn test_yaml_fix_preserves_multiline_values() {
995 let rule = create_enabled_rule();
996 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
997 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
998 let fixed = rule.fix(&ctx).unwrap();
999
1000 let desc_pos = fixed.find("description").unwrap();
1002 let title_pos = fixed.find("title").unwrap();
1003 assert!(desc_pos < title_pos);
1004 }
1005
1006 #[test]
1007 fn test_yaml_quoted_keys() {
1008 let rule = create_enabled_rule();
1009 let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
1010 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1011 let result = rule.check(&ctx).unwrap();
1012
1013 assert!(result.is_empty());
1015 }
1016
1017 #[test]
1018 fn test_yaml_duplicate_keys() {
1019 let rule = create_enabled_rule();
1021 let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1022 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1023 let result = rule.check(&ctx).unwrap();
1024
1025 assert_eq!(result.len(), 1);
1027 }
1028
1029 #[test]
1030 fn test_toml_inline_table() {
1031 let rule = create_enabled_rule();
1032 let content =
1033 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1034 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1035 let result = rule.check(&ctx).unwrap();
1036
1037 assert!(result.is_empty());
1039 }
1040
1041 #[test]
1042 fn test_toml_array_of_tables() {
1043 let rule = create_enabled_rule();
1044 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1045 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1046 let result = rule.check(&ctx).unwrap();
1047
1048 assert_eq!(result.len(), 1);
1050 assert!(result[0].message.contains("'date' should come before 'title'"));
1052 }
1053
1054 #[test]
1055 fn test_json_nested_objects() {
1056 let rule = create_enabled_rule();
1057 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1058 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1059 let result = rule.check(&ctx).unwrap();
1060
1061 assert!(result.is_empty());
1063 }
1064
1065 #[test]
1066 fn test_json_arrays() {
1067 let rule = create_enabled_rule();
1068 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1070 let result = rule.check(&ctx).unwrap();
1071
1072 assert_eq!(result.len(), 1);
1074 }
1075
1076 #[test]
1077 fn test_fix_preserves_content_after_frontmatter() {
1078 let rule = create_enabled_rule();
1079 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081 let fixed = rule.fix(&ctx).unwrap();
1082
1083 assert!(fixed.contains("# Heading"));
1085 assert!(fixed.contains("Paragraph 1."));
1086 assert!(fixed.contains("- List item"));
1087 assert!(fixed.contains("- Another item"));
1088 }
1089
1090 #[test]
1091 fn test_fix_yaml_produces_valid_yaml() {
1092 let rule = create_enabled_rule();
1093 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1094 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1095 let fixed = rule.fix(&ctx).unwrap();
1096
1097 let lines: Vec<&str> = fixed.lines().collect();
1100 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1101 let fm_content: String = lines[1..fm_end].join("\n");
1102
1103 let parsed: Result<serde_yml::Value, _> = serde_yml::from_str(&fm_content);
1105 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1106 }
1107
1108 #[test]
1109 fn test_fix_toml_produces_valid_toml() {
1110 let rule = create_enabled_rule();
1111 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1112 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1113 let fixed = rule.fix(&ctx).unwrap();
1114
1115 let lines: Vec<&str> = fixed.lines().collect();
1117 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1118 let fm_content: String = lines[1..fm_end].join("\n");
1119
1120 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1122 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1123 }
1124
1125 #[test]
1126 fn test_fix_json_produces_valid_json() {
1127 let rule = create_enabled_rule();
1128 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1129 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1130 let fixed = rule.fix(&ctx).unwrap();
1131
1132 let json_end = fixed.find("\n\n").unwrap();
1134 let json_content = &fixed[..json_end];
1135
1136 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1138 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1139 }
1140
1141 #[test]
1142 fn test_many_keys_performance() {
1143 let rule = create_enabled_rule();
1144 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1146 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1148
1149 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1150 let result = rule.check(&ctx).unwrap();
1151
1152 assert_eq!(result.len(), 1);
1154 }
1155
1156 #[test]
1157 fn test_yaml_empty_value() {
1158 let rule = create_enabled_rule();
1159 let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1161 let result = rule.check(&ctx).unwrap();
1162
1163 assert_eq!(result.len(), 1);
1165 }
1166
1167 #[test]
1168 fn test_yaml_null_value() {
1169 let rule = create_enabled_rule();
1170 let content = "---\ntitle: null\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);
1175 }
1176
1177 #[test]
1178 fn test_yaml_boolean_values() {
1179 let rule = create_enabled_rule();
1180 let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1181 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1182 let result = rule.check(&ctx).unwrap();
1183
1184 assert_eq!(result.len(), 1);
1186 }
1187
1188 #[test]
1189 fn test_toml_boolean_values() {
1190 let rule = create_enabled_rule();
1191 let content = "+++\ndraft = true\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);
1196 }
1197
1198 #[test]
1199 fn test_yaml_list_at_top_level() {
1200 let rule = create_enabled_rule();
1201 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1202 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1203 let result = rule.check(&ctx).unwrap();
1204
1205 assert_eq!(result.len(), 1);
1207 }
1208
1209 #[test]
1210 fn test_three_keys_all_orderings() {
1211 let rule = create_enabled_rule();
1212
1213 let orderings = [
1215 ("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), ];
1222
1223 for (name, content, should_pass) in orderings {
1224 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1225 let result = rule.check(&ctx).unwrap();
1226 assert_eq!(
1227 result.is_empty(),
1228 should_pass,
1229 "Ordering {name} should {} pass",
1230 if should_pass { "" } else { "not" }
1231 );
1232 }
1233 }
1234
1235 #[test]
1236 fn test_crlf_line_endings() {
1237 let rule = create_enabled_rule();
1238 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1239 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let result = rule.check(&ctx).unwrap();
1241
1242 assert_eq!(result.len(), 1);
1244 }
1245
1246 #[test]
1247 fn test_json_escaped_quotes_in_keys() {
1248 let rule = create_enabled_rule();
1249 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1251 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1252 let result = rule.check(&ctx).unwrap();
1253
1254 assert_eq!(result.len(), 1);
1256 }
1257
1258 #[test]
1261 fn test_warning_fix_yaml_sorts_keys() {
1262 let rule = create_enabled_rule();
1263 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1264 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1265 let warnings = rule.check(&ctx).unwrap();
1266
1267 assert_eq!(warnings.len(), 1);
1268 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1269
1270 let fix = warnings[0].fix.as_ref().unwrap();
1271 assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1272
1273 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1275
1276 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1278 let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1279 assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1280 }
1281
1282 #[test]
1283 fn test_warning_fix_preserves_yaml_list_indentation() {
1284 let rule = create_enabled_rule();
1285 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1286 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1287 let warnings = rule.check(&ctx).unwrap();
1288
1289 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1290
1291 assert!(
1293 fixed.contains(" - hello"),
1294 "List indentation should be preserved: {fixed}"
1295 );
1296 assert!(
1297 fixed.contains(" - world"),
1298 "List indentation should be preserved: {fixed}"
1299 );
1300 }
1301
1302 #[test]
1303 fn test_warning_fix_preserves_nested_object_indentation() {
1304 let rule = create_enabled_rule();
1305 let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
1306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307 let warnings = rule.check(&ctx).unwrap();
1308
1309 assert_eq!(warnings.len(), 1);
1310 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1311
1312 let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1314 let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1315 assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1316
1317 assert!(
1319 fixed.contains(" nested_key: nested_value"),
1320 "Nested object indentation should be preserved: {fixed}"
1321 );
1322 assert!(
1323 fixed.contains(" another: 123"),
1324 "Nested object indentation should be preserved: {fixed}"
1325 );
1326 }
1327
1328 #[test]
1329 fn test_warning_fix_preserves_deeply_nested_structure() {
1330 let rule = create_enabled_rule();
1331 let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
1332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1333 let warnings = rule.check(&ctx).unwrap();
1334
1335 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1336
1337 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1339 let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1340 assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1341
1342 assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
1344 assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
1345 assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
1346 assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
1347 }
1348
1349 #[test]
1350 fn test_warning_fix_toml_sorts_keys() {
1351 let rule = create_enabled_rule();
1352 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1353 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1354 let warnings = rule.check(&ctx).unwrap();
1355
1356 assert_eq!(warnings.len(), 1);
1357 assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1358
1359 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1360
1361 let author_pos = fixed.find("author").expect("author should exist");
1363 let title_pos = fixed.find("title").expect("title should exist");
1364 assert!(author_pos < title_pos, "author should come before title");
1365 }
1366
1367 #[test]
1368 fn test_warning_fix_json_sorts_keys() {
1369 let rule = create_enabled_rule();
1370 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1372 let warnings = rule.check(&ctx).unwrap();
1373
1374 assert_eq!(warnings.len(), 1);
1375 assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1376
1377 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1378
1379 let author_pos = fixed.find("author").expect("author should exist");
1381 let title_pos = fixed.find("title").expect("title should exist");
1382 assert!(author_pos < title_pos, "author should come before title");
1383 }
1384
1385 #[test]
1386 fn test_warning_fix_no_fix_when_comments_present() {
1387 let rule = create_enabled_rule();
1388 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1390 let warnings = rule.check(&ctx).unwrap();
1391
1392 assert_eq!(warnings.len(), 1);
1393 assert!(
1394 warnings[0].fix.is_none(),
1395 "Warning should NOT have a fix when comments are present"
1396 );
1397 assert!(
1398 warnings[0].message.contains("auto-fix unavailable"),
1399 "Message should indicate auto-fix is unavailable"
1400 );
1401 }
1402
1403 #[test]
1404 fn test_warning_fix_preserves_content_after_frontmatter() {
1405 let rule = create_enabled_rule();
1406 let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1408 let warnings = rule.check(&ctx).unwrap();
1409
1410 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1411
1412 assert!(fixed.contains("# Heading"), "Heading should be preserved");
1414 assert!(
1415 fixed.contains("Paragraph with content."),
1416 "Paragraph should be preserved"
1417 );
1418 assert!(fixed.contains("- List item"), "List item should be preserved");
1419 }
1420
1421 #[test]
1422 fn test_warning_fix_idempotent() {
1423 let rule = create_enabled_rule();
1424 let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1426 let warnings = rule.check(&ctx).unwrap();
1427
1428 let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1429
1430 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1432 let warnings2 = rule.check(&ctx2).unwrap();
1433
1434 assert!(
1435 warnings2.is_empty(),
1436 "After fixing, no more warnings should be produced"
1437 );
1438 }
1439
1440 #[test]
1441 fn test_warning_fix_preserves_multiline_block_literal() {
1442 let rule = create_enabled_rule();
1443 let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
1444 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1445 let warnings = rule.check(&ctx).unwrap();
1446
1447 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1448
1449 assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1451 assert!(
1452 fixed.contains(" Line 1 of block"),
1453 "Block literal line 1 should be preserved with indent"
1454 );
1455 assert!(
1456 fixed.contains(" Line 2 of block"),
1457 "Block literal line 2 should be preserved with indent"
1458 );
1459 }
1460
1461 #[test]
1462 fn test_warning_fix_preserves_folded_string() {
1463 let rule = create_enabled_rule();
1464 let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\n";
1465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1466 let warnings = rule.check(&ctx).unwrap();
1467
1468 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1469
1470 assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1472 assert!(
1473 fixed.contains(" Folded line 1"),
1474 "Folded line 1 should be preserved with indent"
1475 );
1476 assert!(
1477 fixed.contains(" Folded line 2"),
1478 "Folded line 2 should be preserved with indent"
1479 );
1480 }
1481
1482 #[test]
1483 fn test_warning_fix_preserves_4_space_indentation() {
1484 let rule = create_enabled_rule();
1485 let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
1487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1488 let warnings = rule.check(&ctx).unwrap();
1489
1490 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1491
1492 assert!(
1494 fixed.contains(" nested: with_4_spaces"),
1495 "4-space indentation should be preserved: {fixed}"
1496 );
1497 assert!(
1498 fixed.contains(" another: value"),
1499 "4-space indentation should be preserved: {fixed}"
1500 );
1501 }
1502
1503 #[test]
1504 fn test_warning_fix_preserves_tab_indentation() {
1505 let rule = create_enabled_rule();
1506 let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1509 let warnings = rule.check(&ctx).unwrap();
1510
1511 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1512
1513 assert!(
1515 fixed.contains("\tnested: with_tab"),
1516 "Tab indentation should be preserved: {fixed}"
1517 );
1518 assert!(
1519 fixed.contains("\tanother: value"),
1520 "Tab indentation should be preserved: {fixed}"
1521 );
1522 }
1523
1524 #[test]
1525 fn test_warning_fix_preserves_inline_list() {
1526 let rule = create_enabled_rule();
1527 let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1529 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1530 let warnings = rule.check(&ctx).unwrap();
1531
1532 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1533
1534 assert!(
1536 fixed.contains("aaa: [one, two, three]"),
1537 "Inline list should be preserved exactly: {fixed}"
1538 );
1539 }
1540
1541 #[test]
1542 fn test_warning_fix_preserves_quoted_strings() {
1543 let rule = create_enabled_rule();
1544 let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1546 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1547 let warnings = rule.check(&ctx).unwrap();
1548
1549 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1550
1551 assert!(
1553 fixed.contains("aaa: \"value with: colon\""),
1554 "Double-quoted string should be preserved: {fixed}"
1555 );
1556 assert!(
1557 fixed.contains("bbb: 'single quotes'"),
1558 "Single-quoted string should be preserved: {fixed}"
1559 );
1560 }
1561
1562 #[test]
1565 fn test_yaml_custom_key_order_sorted() {
1566 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1568 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1569 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1570 let result = rule.check(&ctx).unwrap();
1571
1572 assert!(result.is_empty());
1574 }
1575
1576 #[test]
1577 fn test_yaml_custom_key_order_unsorted() {
1578 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1580 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1581 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1582 let result = rule.check(&ctx).unwrap();
1583
1584 assert_eq!(result.len(), 1);
1585 assert!(result[0].message.contains("'date' should come before 'author'"));
1587 }
1588
1589 #[test]
1590 fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1591 let rule = create_rule_with_key_order(vec!["title"]);
1593 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1594 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1595 let result = rule.check(&ctx).unwrap();
1596
1597 assert!(result.is_empty());
1600 }
1601
1602 #[test]
1603 fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1604 let rule = create_rule_with_key_order(vec!["title"]);
1606 let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1607 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1608 let result = rule.check(&ctx).unwrap();
1609
1610 assert_eq!(result.len(), 1);
1612 assert!(result[0].message.contains("'author' should come before 'zebra'"));
1613 }
1614
1615 #[test]
1616 fn test_yaml_custom_key_order_fix() {
1617 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1618 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620 let fixed = rule.fix(&ctx).unwrap();
1621
1622 let title_pos = fixed.find("title:").unwrap();
1624 let date_pos = fixed.find("date:").unwrap();
1625 let author_pos = fixed.find("author:").unwrap();
1626 assert!(
1627 title_pos < date_pos && date_pos < author_pos,
1628 "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1629 );
1630 }
1631
1632 #[test]
1633 fn test_yaml_custom_key_order_fix_with_unlisted() {
1634 let rule = create_rule_with_key_order(vec!["title", "author"]);
1636 let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1638 let fixed = rule.fix(&ctx).unwrap();
1639
1640 let title_pos = fixed.find("title:").unwrap();
1642 let author_pos = fixed.find("author:").unwrap();
1643 let aardvark_pos = fixed.find("aardvark:").unwrap();
1644 let zebra_pos = fixed.find("zebra:").unwrap();
1645
1646 assert!(
1647 title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1648 "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1649 );
1650 }
1651
1652 #[test]
1653 fn test_toml_custom_key_order_sorted() {
1654 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1655 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\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!(result.is_empty());
1660 }
1661
1662 #[test]
1663 fn test_toml_custom_key_order_unsorted() {
1664 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1665 let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1666 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1667 let result = rule.check(&ctx).unwrap();
1668
1669 assert_eq!(result.len(), 1);
1670 assert!(result[0].message.contains("TOML"));
1671 }
1672
1673 #[test]
1674 fn test_json_custom_key_order_sorted() {
1675 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1676 let content = "{\n \"title\": \"Test\",\n \"date\": \"2024-01-01\",\n \"author\": \"John\"\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!(result.is_empty());
1681 }
1682
1683 #[test]
1684 fn test_json_custom_key_order_unsorted() {
1685 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1686 let content = "{\n \"author\": \"John\",\n \"title\": \"Test\",\n \"date\": \"2024-01-01\"\n}\n\n# Heading";
1687 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1688 let result = rule.check(&ctx).unwrap();
1689
1690 assert_eq!(result.len(), 1);
1691 assert!(result[0].message.contains("JSON"));
1692 }
1693
1694 #[test]
1695 fn test_key_order_case_insensitive_match() {
1696 let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1698 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1699 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700 let result = rule.check(&ctx).unwrap();
1701
1702 assert!(result.is_empty());
1704 }
1705
1706 #[test]
1707 fn test_key_order_partial_match() {
1708 let rule = create_rule_with_key_order(vec!["title"]);
1710 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1711 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1712 let result = rule.check(&ctx).unwrap();
1713
1714 assert_eq!(result.len(), 1);
1725 assert!(result[0].message.contains("'author' should come before 'date'"));
1726 }
1727
1728 #[test]
1731 fn test_key_order_empty_array_falls_back_to_alphabetical() {
1732 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1734 enabled: true,
1735 key_order: Some(vec![]),
1736 });
1737 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1739 let result = rule.check(&ctx).unwrap();
1740
1741 assert_eq!(result.len(), 1);
1744 assert!(result[0].message.contains("'author' should come before 'title'"));
1745 }
1746
1747 #[test]
1748 fn test_key_order_single_key() {
1749 let rule = create_rule_with_key_order(vec!["title"]);
1751 let content = "---\ntitle: Test\n---\n\n# Heading";
1752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1753 let result = rule.check(&ctx).unwrap();
1754
1755 assert!(result.is_empty());
1756 }
1757
1758 #[test]
1759 fn test_key_order_all_keys_specified() {
1760 let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
1762 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1764 let result = rule.check(&ctx).unwrap();
1765
1766 assert!(result.is_empty());
1767 }
1768
1769 #[test]
1770 fn test_key_order_no_keys_match() {
1771 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1773 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1775 let result = rule.check(&ctx).unwrap();
1776
1777 assert!(result.is_empty());
1780 }
1781
1782 #[test]
1783 fn test_key_order_no_keys_match_unsorted() {
1784 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1786 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1787 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1788 let result = rule.check(&ctx).unwrap();
1789
1790 assert_eq!(result.len(), 1);
1793 }
1794
1795 #[test]
1796 fn test_key_order_duplicate_keys_in_config() {
1797 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1799 enabled: true,
1800 key_order: Some(vec![
1801 "title".to_string(),
1802 "author".to_string(),
1803 "title".to_string(), ]),
1805 });
1806 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1808 let result = rule.check(&ctx).unwrap();
1809
1810 assert!(result.is_empty());
1812 }
1813
1814 #[test]
1815 fn test_key_order_with_comments_still_skips_fix() {
1816 let rule = create_rule_with_key_order(vec!["title", "author"]);
1818 let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
1819 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1820 let result = rule.check(&ctx).unwrap();
1821
1822 assert_eq!(result.len(), 1);
1824 assert!(result[0].message.contains("auto-fix unavailable"));
1825 assert!(result[0].fix.is_none());
1826 }
1827
1828 #[test]
1829 fn test_toml_custom_key_order_fix() {
1830 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1831 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1832 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1833 let fixed = rule.fix(&ctx).unwrap();
1834
1835 let title_pos = fixed.find("title").unwrap();
1837 let date_pos = fixed.find("date").unwrap();
1838 let author_pos = fixed.find("author").unwrap();
1839 assert!(
1840 title_pos < date_pos && date_pos < author_pos,
1841 "Fixed TOML should have keys in custom order. Got:\n{fixed}"
1842 );
1843 }
1844
1845 #[test]
1846 fn test_json_custom_key_order_fix() {
1847 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1848 let content = "{\n \"author\": \"John\",\n \"date\": \"2024-01-01\",\n \"title\": \"Test\"\n}\n\n# Heading";
1849 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1850 let fixed = rule.fix(&ctx).unwrap();
1851
1852 let title_pos = fixed.find("\"title\"").unwrap();
1854 let date_pos = fixed.find("\"date\"").unwrap();
1855 let author_pos = fixed.find("\"author\"").unwrap();
1856 assert!(
1857 title_pos < date_pos && date_pos < author_pos,
1858 "Fixed JSON should have keys in custom order. Got:\n{fixed}"
1859 );
1860 }
1861
1862 #[test]
1863 fn test_key_order_unicode_keys() {
1864 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1866 enabled: true,
1867 key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
1868 });
1869 let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
1870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1871 let result = rule.check(&ctx).unwrap();
1872
1873 assert!(result.is_empty());
1875 }
1876
1877 #[test]
1878 fn test_key_order_mixed_specified_and_unlisted_boundary() {
1879 let rule = create_rule_with_key_order(vec!["z_last_specified"]);
1881 let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
1882 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1883 let result = rule.check(&ctx).unwrap();
1884
1885 assert!(result.is_empty());
1888 }
1889
1890 #[test]
1891 fn test_key_order_fix_preserves_values() {
1892 let rule = create_rule_with_key_order(vec!["title", "tags"]);
1894 let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n\n# Heading";
1895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1896 let fixed = rule.fix(&ctx).unwrap();
1897
1898 let title_pos = fixed.find("title:").unwrap();
1900 let tags_pos = fixed.find("tags:").unwrap();
1901 assert!(title_pos < tags_pos, "title should come before tags");
1902
1903 assert!(fixed.contains("- rust"), "List items should be preserved");
1905 assert!(fixed.contains("- markdown"), "List items should be preserved");
1906 }
1907
1908 #[test]
1909 fn test_key_order_idempotent_fix() {
1910 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1912 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1913 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1914
1915 let fixed_once = rule.fix(&ctx).unwrap();
1916 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1917 let fixed_twice = rule.fix(&ctx2).unwrap();
1918
1919 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1920 }
1921
1922 #[test]
1923 fn test_key_order_respects_later_position_over_alphabetical() {
1924 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
1926 let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
1927 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1928 let result = rule.check(&ctx).unwrap();
1929
1930 assert!(result.is_empty());
1932 }
1933
1934 #[test]
1937 fn test_json_braces_in_string_values_extracts_all_keys() {
1938 let rule = create_enabled_rule();
1942 let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
1943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1944 let result = rule.check(&ctx).unwrap();
1945
1946 assert!(
1948 result.is_empty(),
1949 "All keys should be extracted and recognized as sorted. Got: {result:?}"
1950 );
1951 }
1952
1953 #[test]
1954 fn test_json_braces_in_string_key_after_brace_value_detected() {
1955 let rule = create_enabled_rule();
1957 let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
1960 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1961 let result = rule.check(&ctx).unwrap();
1962
1963 assert_eq!(
1966 result.len(),
1967 1,
1968 "Should detect unsorted keys after brace-containing string value"
1969 );
1970 assert!(
1971 result[0].message.contains("'author' should come before 'description'"),
1972 "Should report author before description. Got: {}",
1973 result[0].message
1974 );
1975 }
1976
1977 #[test]
1978 fn test_json_brackets_in_string_values() {
1979 let rule = create_enabled_rule();
1981 let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
1982 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1983 let result = rule.check(&ctx).unwrap();
1984
1985 assert_eq!(
1987 result.len(),
1988 1,
1989 "Should detect unsorted keys despite brackets in string values"
1990 );
1991 assert!(
1992 result[0].message.contains("'author' should come before 'description'"),
1993 "Got: {}",
1994 result[0].message
1995 );
1996 }
1997
1998 #[test]
1999 fn test_json_escaped_quotes_in_values() {
2000 let rule = create_enabled_rule();
2002 let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2003 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2004 let result = rule.check(&ctx).unwrap();
2005
2006 assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
2008 assert!(
2009 result[0].message.contains("'author' should come before 'title'"),
2010 "Got: {}",
2011 result[0].message
2012 );
2013 }
2014
2015 #[test]
2016 fn test_json_multiple_braces_in_string() {
2017 let rule = create_enabled_rule();
2019 let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2020 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2021 let result = rule.check(&ctx).unwrap();
2022
2023 assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
2025 assert!(
2026 result[0].message.contains("'author' should come before 'pattern'"),
2027 "Got: {}",
2028 result[0].message
2029 );
2030 }
2031
2032 #[test]
2033 fn test_key_order_detects_wrong_custom_order() {
2034 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2036 let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
2037 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2038 let result = rule.check(&ctx).unwrap();
2039
2040 assert_eq!(result.len(), 1);
2041 assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
2042 }
2043}