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 #[serde(default, alias = "required-keys")]
41 pub required_keys: Vec<String>,
42}
43
44impl RuleConfig for MD072Config {
45 const RULE_NAME: &'static str = "MD072";
46}
47
48#[derive(Clone, Default)]
61pub struct MD072FrontmatterKeySort {
62 config: MD072Config,
63}
64
65impl MD072FrontmatterKeySort {
66 pub fn new() -> Self {
67 Self::default()
68 }
69
70 pub fn from_config_struct(config: MD072Config) -> Self {
72 Self { config }
73 }
74
75 fn has_comments(frontmatter_lines: &[&str]) -> bool {
77 frontmatter_lines.iter().any(|line| line.trim_start().starts_with('#'))
78 }
79
80 fn extract_yaml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
82 let mut keys = Vec::new();
83
84 for (idx, line) in frontmatter_lines.iter().enumerate() {
85 if !line.starts_with(' ')
89 && !line.starts_with('\t')
90 && let Some(colon_pos) = FrontMatterUtils::separator_pos_outside_quoted_key(line, ':')
91 {
92 let raw = line[..colon_pos].trim();
93 if !raw.is_empty() && !raw.starts_with('#') {
94 let key = raw
99 .strip_prefix('"')
100 .and_then(|k| k.strip_suffix('"'))
101 .or_else(|| raw.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
102 .unwrap_or(raw);
103 keys.push((idx, key.to_string()));
104 }
105 }
106 }
107
108 keys
109 }
110
111 fn extract_toml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
113 let mut keys = Vec::new();
114
115 for (idx, line) in frontmatter_lines.iter().enumerate() {
116 let trimmed = line.trim();
117 if trimmed.is_empty() || trimmed.starts_with('#') {
119 continue;
120 }
121 if trimmed.starts_with('[') {
123 break;
124 }
125 if !line.starts_with(' ')
129 && !line.starts_with('\t')
130 && let Some(eq_pos) = FrontMatterUtils::separator_pos_outside_quoted_key(line, '=')
131 {
132 let raw = line[..eq_pos].trim();
133 if !raw.is_empty() {
134 let key = raw
139 .strip_prefix('"')
140 .and_then(|k| k.strip_suffix('"'))
141 .or_else(|| raw.strip_prefix('\'').and_then(|k| k.strip_suffix('\'')))
142 .unwrap_or(raw);
143 keys.push((idx, key.to_string()));
144 }
145 }
146 }
147
148 keys
149 }
150
151 fn extract_json_keys(frontmatter_lines: &[&str]) -> Vec<String> {
153 let mut keys = Vec::new();
158 let mut depth: usize = 0;
159
160 for line in frontmatter_lines {
161 let line_start_depth = depth;
163
164 let mut in_string = false;
166 let mut prev_backslash = false;
167 for ch in line.chars() {
168 if in_string {
169 if ch == '"' && !prev_backslash {
170 in_string = false;
171 }
172 prev_backslash = ch == '\\' && !prev_backslash;
173 } else {
174 match ch {
175 '"' => in_string = true,
176 '{' | '[' => depth += 1,
177 '}' | ']' => depth = depth.saturating_sub(1),
178 _ => {}
179 }
180 prev_backslash = false;
181 }
182 }
183
184 if line_start_depth == 0
186 && let Some(captures) = JSON_KEY_PATTERN.captures(line)
187 && let Some(key_match) = captures.get(1)
188 {
189 keys.push(key_match.as_str().to_string());
190 }
191 }
192
193 keys
194 }
195
196 fn key_sort_position(key: &str, key_order: Option<&[String]>) -> (usize, String) {
200 if let Some(order) = key_order {
201 let key_lower = key.to_lowercase();
203 for (idx, ordered_key) in order.iter().enumerate() {
204 if ordered_key.to_lowercase() == key_lower {
205 return (idx, key_lower);
206 }
207 }
208 (usize::MAX, key_lower)
210 } else {
211 (0, key.to_lowercase())
213 }
214 }
215
216 fn find_first_unsorted_pair<'a>(keys: &'a [String], key_order: Option<&[String]>) -> Option<(&'a str, &'a str)> {
219 for i in 1..keys.len() {
220 let pos_curr = Self::key_sort_position(&keys[i], key_order);
221 let pos_prev = Self::key_sort_position(&keys[i - 1], key_order);
222 if pos_curr < pos_prev {
223 return Some((&keys[i], &keys[i - 1]));
224 }
225 }
226 None
227 }
228
229 fn find_first_unsorted_indexed_pair<'a>(
232 keys: &'a [(usize, String)],
233 key_order: Option<&[String]>,
234 ) -> Option<(usize, &'a str, &'a str)> {
235 for i in 1..keys.len() {
236 let pos_curr = Self::key_sort_position(&keys[i].1, key_order);
237 let pos_prev = Self::key_sort_position(&keys[i - 1].1, key_order);
238 if pos_curr < pos_prev {
239 return Some((keys[i].0, &keys[i].1, &keys[i - 1].1));
240 }
241 }
242 None
243 }
244
245 fn are_keys_sorted(keys: &[String], key_order: Option<&[String]>) -> bool {
247 Self::find_first_unsorted_pair(keys, key_order).is_none()
248 }
249
250 fn are_indexed_keys_sorted(keys: &[(usize, String)], key_order: Option<&[String]>) -> bool {
252 Self::find_first_unsorted_indexed_pair(keys, key_order).is_none()
253 }
254
255 fn sort_keys_by_order(keys: &mut [(String, Vec<&str>)], key_order: Option<&[String]>) {
257 keys.sort_by(|a, b| {
258 let pos_a = Self::key_sort_position(&a.0, key_order);
259 let pos_b = Self::key_sort_position(&b.0, key_order);
260 pos_a.cmp(&pos_b)
261 });
262 }
263
264 fn extract_toml_presence_keys(frontmatter_lines: &[&str]) -> Vec<String> {
272 let mut keys = Vec::new();
273 let mut in_tables = false;
274
275 for line in frontmatter_lines {
276 let trimmed = line.trim();
277 if trimmed.is_empty() || trimmed.starts_with('#') {
278 continue;
279 }
280 if trimmed.starts_with('[') {
281 in_tables = true;
282 let inner = trimmed
285 .strip_prefix("[[")
286 .and_then(|s| s.split_once("]]").map(|(inner, _)| inner))
287 .or_else(|| {
288 trimmed
289 .strip_prefix('[')
290 .and_then(|s| s.split_once(']').map(|(inner, _)| inner))
291 });
292 if let Some(inner) = inner {
293 let key = FrontMatterUtils::toml_root_key(inner.trim());
294 if !key.is_empty() {
295 keys.push(key.to_string());
296 }
297 }
298 continue;
299 }
300 if !in_tables
301 && !line.starts_with(' ')
302 && !line.starts_with('\t')
303 && let Some(eq_pos) = FrontMatterUtils::separator_pos_outside_quoted_key(line, '=')
304 {
305 let key = FrontMatterUtils::toml_root_key(line[..eq_pos].trim());
306 if !key.is_empty() {
307 keys.push(key.to_string());
308 }
309 }
310 }
311
312 keys
313 }
314
315 fn parse_json_top_level_keys(frontmatter_lines: &[&str]) -> Option<Vec<String>> {
319 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
320 match serde_json::from_str::<serde_json::Value>(&json_content) {
321 Ok(serde_json::Value::Object(map)) => Some(map.keys().cloned().collect()),
322 _ => None,
323 }
324 }
325
326 fn missing_required_key_warnings(
336 &self,
337 present_keys: &[String],
338 format: &str,
339 fence_len: usize,
340 fm_end_line: usize,
341 ) -> Vec<LintWarning> {
342 if self.config.required_keys.is_empty() {
343 return Vec::new();
344 }
345
346 let present: Vec<String> = present_keys.iter().map(|k| k.to_lowercase()).collect();
347 self.config
348 .required_keys
349 .iter()
350 .filter(|required| !present.contains(&required.to_lowercase()))
351 .map(|required| LintWarning {
352 rule_name: Some(self.name().to_string()),
353 message: format!("{format} frontmatter is missing required key '{required}'"),
354 line: 1,
355 column: 1,
356 end_line: fm_end_line.max(1),
357 end_column: fence_len + 1,
358 severity: Severity::Warning,
359 fix: None,
360 })
361 .collect()
362 }
363}
364
365impl Rule for MD072FrontmatterKeySort {
366 fn name(&self) -> &'static str {
367 "MD072"
368 }
369
370 fn description(&self) -> &'static str {
371 "Frontmatter keys should be sorted alphabetically"
372 }
373
374 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
375 let content = ctx.content;
376 let mut warnings = Vec::new();
377
378 if content.is_empty() {
379 return Ok(warnings);
380 }
381
382 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
383
384 match fm_type {
385 FrontMatterType::Yaml => {
386 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
387 let keys = Self::extract_yaml_keys(&frontmatter_lines);
388
389 let key_names: Vec<String> = keys.iter().map(|(_, key)| key.clone()).collect();
390 warnings.extend(self.missing_required_key_warnings(&key_names, "YAML", 3, ctx.front_matter_end_line()));
391
392 if frontmatter_lines.is_empty() {
393 return Ok(warnings);
394 }
395
396 let key_order = self.config.key_order.as_deref();
397 let Some((key_idx, out_of_place, should_come_after)) =
398 Self::find_first_unsorted_indexed_pair(&keys, key_order)
399 else {
400 return Ok(warnings);
401 };
402 let key_line = key_idx + 2;
404
405 let has_comments = Self::has_comments(&frontmatter_lines);
406
407 let fix = if has_comments {
408 None
409 } else {
410 let fixed_content = self.fix_yaml(content, ctx.front_matter_end_line());
412 if fixed_content != content {
413 Some(Fix::new(0..content.len(), fixed_content))
414 } else {
415 None
416 }
417 };
418
419 let message = if has_comments {
420 format!(
421 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
422 )
423 } else {
424 format!(
425 "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
426 )
427 };
428
429 let end_column = frontmatter_lines
432 .get(key_idx)
433 .and_then(|line| {
434 FrontMatterUtils::separator_pos_outside_quoted_key(line, ':')
435 .map(|pos| line[..pos].trim().chars().count() + 1)
436 })
437 .unwrap_or(out_of_place.chars().count() + 1);
438
439 warnings.push(LintWarning {
440 rule_name: Some(self.name().to_string()),
441 message,
442 line: key_line,
443 column: 1,
444 end_line: key_line,
445 end_column,
446 severity: Severity::Warning,
447 fix,
448 });
449 }
450 FrontMatterType::Toml => {
451 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
452 let keys = Self::extract_toml_keys(&frontmatter_lines);
453
454 let key_names = Self::extract_toml_presence_keys(&frontmatter_lines);
458 warnings.extend(self.missing_required_key_warnings(&key_names, "TOML", 3, ctx.front_matter_end_line()));
459
460 if frontmatter_lines.is_empty() {
461 return Ok(warnings);
462 }
463
464 let key_order = self.config.key_order.as_deref();
465 let Some((key_idx, out_of_place, should_come_after)) =
466 Self::find_first_unsorted_indexed_pair(&keys, key_order)
467 else {
468 return Ok(warnings);
469 };
470 let key_line = key_idx + 2;
471
472 let has_comments = Self::has_comments(&frontmatter_lines);
473
474 let fix = if has_comments {
475 None
476 } else {
477 let fixed_content = self.fix_toml(content, ctx.front_matter_end_line());
479 if fixed_content != content {
480 Some(Fix::new(0..content.len(), fixed_content))
481 } else {
482 None
483 }
484 };
485
486 let message = if has_comments {
487 format!(
488 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
489 )
490 } else {
491 format!(
492 "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
493 )
494 };
495
496 let end_column = frontmatter_lines
499 .get(key_idx)
500 .and_then(|line| {
501 FrontMatterUtils::separator_pos_outside_quoted_key(line, '=')
502 .map(|pos| line[..pos].trim().chars().count() + 1)
503 })
504 .unwrap_or(out_of_place.chars().count() + 1);
505
506 warnings.push(LintWarning {
507 rule_name: Some(self.name().to_string()),
508 message,
509 line: key_line,
510 column: 1,
511 end_line: key_line,
512 end_column,
513 severity: Severity::Warning,
514 fix,
515 });
516 }
517 FrontMatterType::Json => {
518 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
519 let keys = Self::extract_json_keys(&frontmatter_lines);
520
521 let parsed_keys = Self::parse_json_top_level_keys(&frontmatter_lines);
528 warnings.extend(self.missing_required_key_warnings(
529 parsed_keys.as_deref().unwrap_or(&keys),
530 "JSON",
531 1,
532 ctx.front_matter_end_line(),
533 ));
534
535 if frontmatter_lines.is_empty() {
536 return Ok(warnings);
537 }
538
539 let key_order = self.config.key_order.as_deref();
540 let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_pair(&keys, key_order) else {
541 return Ok(warnings);
542 };
543
544 let fixed_content = self.fix_json(content, ctx.front_matter_end_line());
546 let fix = if fixed_content != content {
547 Some(Fix::new(0..content.len(), fixed_content))
548 } else {
549 None
550 };
551
552 let message = format!(
553 "JSON frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
554 );
555
556 warnings.push(LintWarning {
557 rule_name: Some(self.name().to_string()),
558 message,
559 line: 2,
560 column: 1,
561 end_line: 2,
562 end_column: out_of_place.chars().count() + 1,
563 severity: Severity::Warning,
564 fix,
565 });
566 }
567 _ => {
568 }
570 }
571
572 Ok(warnings)
573 }
574
575 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
576 let content = ctx.content;
577
578 if ctx.is_rule_disabled(self.name(), 2) {
580 return Ok(content.to_string());
581 }
582
583 let fm_type = FrontMatterUtils::detect_front_matter_type(content);
584
585 let fm_end = ctx.front_matter_end_line();
586 Ok(match fm_type {
587 FrontMatterType::Yaml => self.fix_yaml(content, fm_end),
588 FrontMatterType::Toml => self.fix_toml(content, fm_end),
589 FrontMatterType::Json => self.fix_json(content, fm_end),
590 _ => content.to_string(),
591 })
592 }
593
594 fn category(&self) -> RuleCategory {
595 RuleCategory::FrontMatter
596 }
597
598 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
599 ctx.content.is_empty()
600 || !ctx.content.starts_with("---") && !ctx.content.starts_with("+++") && !ctx.content.starts_with('{')
601 }
602
603 fn as_any(&self) -> &dyn std::any::Any {
604 self
605 }
606
607 crate::impl_rule_config_methods!(MD072Config, nullable);
608}
609
610impl MD072FrontmatterKeySort {
611 fn preserve_trailing_newline(original: &str, mut result: String) -> String {
616 if original.ends_with('\n') && !result.ends_with('\n') {
617 result.push('\n');
618 }
619 result
620 }
621
622 fn fix_yaml(&self, content: &str, fm_end: usize) -> String {
623 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
624 if frontmatter_lines.is_empty() {
625 return content.to_string();
626 }
627
628 if Self::has_comments(&frontmatter_lines) {
630 return content.to_string();
631 }
632
633 let keys = Self::extract_yaml_keys(&frontmatter_lines);
634 let key_order = self.config.key_order.as_deref();
635 if Self::are_indexed_keys_sorted(&keys, key_order) {
636 return content.to_string();
637 }
638
639 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
642
643 for (i, (line_idx, key)) in keys.iter().enumerate() {
644 let start = *line_idx;
645 let end = if i + 1 < keys.len() {
646 keys[i + 1].0
647 } else {
648 frontmatter_lines.len()
649 };
650
651 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
652 key_blocks.push((key.clone(), block_lines));
653 }
654
655 Self::sort_keys_by_order(&mut key_blocks, key_order);
657
658 let content_lines: Vec<&str> = content.lines().collect();
660
661 let mut result = String::new();
662 result.push_str("---\n");
663 for (_, lines) in &key_blocks {
664 for line in lines {
665 result.push_str(line);
666 result.push('\n');
667 }
668 }
669 result.push_str("---");
670
671 if fm_end < content_lines.len() {
672 result.push('\n');
673 result.push_str(&content_lines[fm_end..].join("\n"));
674 }
675
676 Self::preserve_trailing_newline(content, result)
677 }
678
679 fn fix_toml(&self, content: &str, fm_end: usize) -> String {
680 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
681 if frontmatter_lines.is_empty() {
682 return content.to_string();
683 }
684
685 if Self::has_comments(&frontmatter_lines) {
687 return content.to_string();
688 }
689
690 let keys = Self::extract_toml_keys(&frontmatter_lines);
691 let key_order = self.config.key_order.as_deref();
692 if Self::are_indexed_keys_sorted(&keys, key_order) {
693 return content.to_string();
694 }
695
696 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
699
700 for (i, (line_idx, key)) in keys.iter().enumerate() {
701 let start = *line_idx;
702 let end = if i + 1 < keys.len() {
703 keys[i + 1].0
704 } else {
705 frontmatter_lines.len()
706 };
707
708 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
709 key_blocks.push((key.clone(), block_lines));
710 }
711
712 Self::sort_keys_by_order(&mut key_blocks, key_order);
714
715 let content_lines: Vec<&str> = content.lines().collect();
717
718 let mut result = String::new();
719 result.push_str("+++\n");
720 for (_, lines) in &key_blocks {
721 for line in lines {
722 result.push_str(line);
723 result.push('\n');
724 }
725 }
726 result.push_str("+++");
727
728 if fm_end < content_lines.len() {
729 result.push('\n');
730 result.push_str(&content_lines[fm_end..].join("\n"));
731 }
732
733 Self::preserve_trailing_newline(content, result)
734 }
735
736 fn fix_json(&self, content: &str, fm_end: usize) -> String {
737 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
738 if frontmatter_lines.is_empty() {
739 return content.to_string();
740 }
741
742 let keys = Self::extract_json_keys(&frontmatter_lines);
743 let key_order = self.config.key_order.as_deref();
744
745 if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
746 return content.to_string();
747 }
748
749 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
751
752 match serde_json::from_str::<serde_json::Value>(&json_content) {
754 Ok(serde_json::Value::Object(map)) => {
755 let mut sorted_map = serde_json::Map::new();
757 let mut keys: Vec<_> = map.keys().cloned().collect();
758 keys.sort_by(|a, b| {
759 let pos_a = Self::key_sort_position(a, key_order);
760 let pos_b = Self::key_sort_position(b, key_order);
761 pos_a.cmp(&pos_b)
762 });
763
764 for key in keys {
765 if let Some(value) = map.get(&key) {
766 sorted_map.insert(key, value.clone());
767 }
768 }
769
770 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
771 Ok(sorted_json) => {
772 let lines: Vec<&str> = content.lines().collect();
773
774 let mut result = String::new();
777 result.push_str(&sorted_json);
778
779 if fm_end < lines.len() {
780 result.push('\n');
781 result.push_str(&lines[fm_end..].join("\n"));
782 }
783
784 Self::preserve_trailing_newline(content, result)
785 }
786 Err(_) => content.to_string(),
787 }
788 }
789 _ => content.to_string(),
790 }
791 }
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797 use crate::lint_context::LintContext;
798
799 fn create_enabled_rule() -> MD072FrontmatterKeySort {
801 MD072FrontmatterKeySort::from_config_struct(MD072Config {
802 enabled: true,
803 ..Default::default()
804 })
805 }
806
807 fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
809 MD072FrontmatterKeySort::from_config_struct(MD072Config {
810 enabled: true,
811 key_order: Some(keys.into_iter().map(String::from).collect()),
812 ..Default::default()
813 })
814 }
815
816 fn create_rule_with_required_keys(keys: Vec<&str>) -> MD072FrontmatterKeySort {
818 MD072FrontmatterKeySort::from_config_struct(MD072Config {
819 enabled: true,
820 required_keys: keys.into_iter().map(String::from).collect(),
821 ..Default::default()
822 })
823 }
824
825 #[test]
828 fn test_enabled_via_config() {
829 let rule = create_enabled_rule();
830 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
831 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
832 let result = rule.check(&ctx).unwrap();
833
834 assert_eq!(result.len(), 1);
836 }
837
838 #[test]
841 fn test_no_frontmatter() {
842 let rule = create_enabled_rule();
843 let content = "# Heading\n\nContent.";
844 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
845 let result = rule.check(&ctx).unwrap();
846
847 assert!(result.is_empty());
848 }
849
850 #[test]
851 fn test_yaml_sorted_keys() {
852 let rule = create_enabled_rule();
853 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
854 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855 let result = rule.check(&ctx).unwrap();
856
857 assert!(result.is_empty());
858 }
859
860 #[test]
861 fn test_yaml_unsorted_keys() {
862 let rule = create_enabled_rule();
863 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
864 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
865 let result = rule.check(&ctx).unwrap();
866
867 assert_eq!(result.len(), 1);
868 assert!(result[0].message.contains("YAML"));
869 assert!(result[0].message.contains("not sorted"));
870 assert!(result[0].message.contains("'author' should come before 'title'"));
872 }
873
874 #[test]
875 fn test_yaml_case_insensitive_sort() {
876 let rule = create_enabled_rule();
877 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
878 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
879 let result = rule.check(&ctx).unwrap();
880
881 assert!(result.is_empty());
883 }
884
885 #[test]
886 fn test_yaml_fix_sorts_keys() {
887 let rule = create_enabled_rule();
888 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
889 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
890 let fixed = rule.fix(&ctx).unwrap();
891
892 let author_pos = fixed.find("author:").unwrap();
894 let title_pos = fixed.find("title:").unwrap();
895 assert!(author_pos < title_pos);
896 }
897
898 #[test]
899 fn test_yaml_no_fix_with_comments() {
900 let rule = create_enabled_rule();
901 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
902 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
903 let result = rule.check(&ctx).unwrap();
904
905 assert_eq!(result.len(), 1);
906 assert!(result[0].message.contains("auto-fix unavailable"));
907 assert!(result[0].fix.is_none());
908
909 let fixed = rule.fix(&ctx).unwrap();
911 assert_eq!(fixed, content);
912 }
913
914 #[test]
915 fn test_yaml_single_key() {
916 let rule = create_enabled_rule();
917 let content = "---\ntitle: Test\n---\n\n# Heading";
918 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919 let result = rule.check(&ctx).unwrap();
920
921 assert!(result.is_empty());
923 }
924
925 #[test]
926 fn test_yaml_nested_keys_ignored() {
927 let rule = create_enabled_rule();
928 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
930 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931 let result = rule.check(&ctx).unwrap();
932
933 assert!(result.is_empty());
935 }
936
937 #[test]
938 fn test_yaml_fix_idempotent() {
939 let rule = create_enabled_rule();
940 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
941 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
942 let fixed_once = rule.fix(&ctx).unwrap();
943
944 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
945 let fixed_twice = rule.fix(&ctx2).unwrap();
946
947 assert_eq!(fixed_once, fixed_twice);
948 }
949
950 #[test]
951 fn test_yaml_fix_preserves_trailing_newline() {
952 let rule = create_enabled_rule();
953 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n";
955 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let fixed = rule.fix(&ctx).unwrap();
957 assert!(
958 fixed.ends_with('\n'),
959 "trailing newline must be preserved, got {fixed:?}"
960 );
961
962 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
964 let fixed_twice = rule.fix(&ctx2).unwrap();
965 assert_eq!(fixed, fixed_twice);
966 }
967
968 #[test]
969 fn test_yaml_fix_whole_file_frontmatter_preserves_trailing_newline() {
970 let rule = create_enabled_rule();
971 let content = "---\ntitle: Test\nauthor: John\n---\n";
973 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
974 let fixed = rule.fix(&ctx).unwrap();
975 assert!(
976 fixed.ends_with('\n'),
977 "trailing newline must be preserved, got {fixed:?}"
978 );
979 }
980
981 #[test]
982 fn test_yaml_quoted_keys_sort_by_content() {
983 let rule = create_enabled_rule();
984 let content = "---\n\"zebra\": 1\napple: 2\n---\n\n# Heading";
987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
988 let result = rule.check(&ctx).unwrap();
989
990 assert_eq!(result.len(), 1, "quoted key out of order must be flagged");
991 assert!(result[0].message.contains("'apple' should come before 'zebra'"));
992 }
993
994 #[test]
995 fn test_yaml_quoted_key_warning_span_covers_quotes() {
996 let rule = create_enabled_rule();
997 let content = "---\nbanana: 1\n\"apple\": 2\n---\n";
1001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1002 let result = rule.check(&ctx).unwrap();
1003
1004 assert_eq!(result.len(), 1);
1005 let w = &result[0];
1006 assert_eq!(w.line, 3);
1007 assert_eq!(w.column, 1);
1008 assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
1010 }
1011
1012 #[test]
1013 fn test_yaml_complex_values() {
1014 let rule = create_enabled_rule();
1015 let content =
1017 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
1018 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1019 let result = rule.check(&ctx).unwrap();
1020
1021 assert!(result.is_empty());
1023 }
1024
1025 #[test]
1028 fn test_toml_sorted_keys() {
1029 let rule = create_enabled_rule();
1030 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1031 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032 let result = rule.check(&ctx).unwrap();
1033
1034 assert!(result.is_empty());
1035 }
1036
1037 #[test]
1038 fn test_toml_unsorted_keys() {
1039 let rule = create_enabled_rule();
1040 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
1041 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1042 let result = rule.check(&ctx).unwrap();
1043
1044 assert_eq!(result.len(), 1);
1045 assert!(result[0].message.contains("TOML"));
1046 assert!(result[0].message.contains("not sorted"));
1047 }
1048
1049 #[test]
1050 fn test_toml_fix_sorts_keys() {
1051 let rule = create_enabled_rule();
1052 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
1053 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1054 let fixed = rule.fix(&ctx).unwrap();
1055
1056 let author_pos = fixed.find("author").unwrap();
1058 let title_pos = fixed.find("title").unwrap();
1059 assert!(author_pos < title_pos);
1060 }
1061
1062 #[test]
1063 fn test_toml_no_fix_with_comments() {
1064 let rule = create_enabled_rule();
1065 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
1066 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067 let result = rule.check(&ctx).unwrap();
1068
1069 assert_eq!(result.len(), 1);
1070 assert!(result[0].message.contains("auto-fix unavailable"));
1071
1072 let fixed = rule.fix(&ctx).unwrap();
1074 assert_eq!(fixed, content);
1075 }
1076
1077 #[test]
1080 fn test_json_sorted_keys() {
1081 let rule = create_enabled_rule();
1082 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
1083 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1084 let result = rule.check(&ctx).unwrap();
1085
1086 assert!(result.is_empty());
1087 }
1088
1089 #[test]
1090 fn test_json_unsorted_keys() {
1091 let rule = create_enabled_rule();
1092 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1093 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1094 let result = rule.check(&ctx).unwrap();
1095
1096 assert_eq!(result.len(), 1);
1097 assert!(result[0].message.contains("JSON"));
1098 assert!(result[0].message.contains("not sorted"));
1099 }
1100
1101 #[test]
1102 fn test_json_fix_sorts_keys() {
1103 let rule = create_enabled_rule();
1104 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106 let fixed = rule.fix(&ctx).unwrap();
1107
1108 let author_pos = fixed.find("author").unwrap();
1110 let title_pos = fixed.find("title").unwrap();
1111 assert!(author_pos < title_pos);
1112 }
1113
1114 #[test]
1115 fn test_json_always_fixable() {
1116 let rule = create_enabled_rule();
1117 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1119 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1120 let result = rule.check(&ctx).unwrap();
1121
1122 assert_eq!(result.len(), 1);
1123 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
1125 }
1126
1127 #[test]
1130 fn test_empty_content() {
1131 let rule = create_enabled_rule();
1132 let content = "";
1133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1134 let result = rule.check(&ctx).unwrap();
1135
1136 assert!(result.is_empty());
1137 }
1138
1139 #[test]
1140 fn test_empty_frontmatter() {
1141 let rule = create_enabled_rule();
1142 let content = "---\n---\n\n# Heading";
1143 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1144 let result = rule.check(&ctx).unwrap();
1145
1146 assert!(result.is_empty());
1147 }
1148
1149 #[test]
1150 fn test_toml_nested_tables_ignored() {
1151 let rule = create_enabled_rule();
1153 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
1154 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155 let result = rule.check(&ctx).unwrap();
1156
1157 assert_eq!(result.len(), 1);
1159 assert!(result[0].message.contains("'sort_by' should come before 'title'"));
1161 assert!(!result[0].message.contains("we_have_extra"));
1162 }
1163
1164 #[test]
1165 fn test_toml_nested_taxonomies_ignored() {
1166 let rule = create_enabled_rule();
1168 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
1169 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1170 let result = rule.check(&ctx).unwrap();
1171
1172 assert_eq!(result.len(), 1);
1174 assert!(result[0].message.contains("'date' should come before 'title'"));
1176 assert!(!result[0].message.contains("categories"));
1177 assert!(!result[0].message.contains("tags"));
1178 }
1179
1180 #[test]
1183 fn test_yaml_unicode_keys() {
1184 let rule = create_enabled_rule();
1185 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
1187 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1188 let result = rule.check(&ctx).unwrap();
1189
1190 assert_eq!(result.len(), 1);
1192 }
1193
1194 #[test]
1195 fn test_yaml_keys_with_special_characters() {
1196 let rule = create_enabled_rule();
1197 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
1199 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200 let result = rule.check(&ctx).unwrap();
1201
1202 assert!(result.is_empty());
1204 }
1205
1206 #[test]
1207 fn test_yaml_keys_with_numbers() {
1208 let rule = create_enabled_rule();
1209 let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
1210 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1211 let result = rule.check(&ctx).unwrap();
1212
1213 assert!(result.is_empty());
1215 }
1216
1217 #[test]
1218 fn test_yaml_multiline_string_block_literal() {
1219 let rule = create_enabled_rule();
1220 let content =
1221 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
1222 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1223 let result = rule.check(&ctx).unwrap();
1224
1225 assert_eq!(result.len(), 1);
1227 assert!(result[0].message.contains("'author' should come before 'title'"));
1228 }
1229
1230 #[test]
1231 fn test_yaml_multiline_string_folded() {
1232 let rule = create_enabled_rule();
1233 let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\n---\n\n# Heading";
1234 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1235 let result = rule.check(&ctx).unwrap();
1236
1237 assert_eq!(result.len(), 1);
1239 }
1240
1241 #[test]
1242 fn test_yaml_fix_preserves_multiline_values() {
1243 let rule = create_enabled_rule();
1244 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
1245 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1246 let fixed = rule.fix(&ctx).unwrap();
1247
1248 let desc_pos = fixed.find("description").unwrap();
1250 let title_pos = fixed.find("title").unwrap();
1251 assert!(desc_pos < title_pos);
1252 }
1253
1254 #[test]
1255 fn test_yaml_quoted_keys() {
1256 let rule = create_enabled_rule();
1257 let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
1258 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1259 let result = rule.check(&ctx).unwrap();
1260
1261 assert!(result.is_empty());
1263 }
1264
1265 #[test]
1266 fn test_yaml_duplicate_keys() {
1267 let rule = create_enabled_rule();
1269 let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1270 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1271 let result = rule.check(&ctx).unwrap();
1272
1273 assert_eq!(result.len(), 1);
1275 }
1276
1277 #[test]
1278 fn test_toml_inline_table() {
1279 let rule = create_enabled_rule();
1280 let content =
1281 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1282 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1283 let result = rule.check(&ctx).unwrap();
1284
1285 assert!(result.is_empty());
1287 }
1288
1289 #[test]
1290 fn test_toml_array_of_tables() {
1291 let rule = create_enabled_rule();
1292 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1294 let result = rule.check(&ctx).unwrap();
1295
1296 assert_eq!(result.len(), 1);
1298 assert!(result[0].message.contains("'date' should come before 'title'"));
1300 }
1301
1302 #[test]
1303 fn test_json_nested_objects() {
1304 let rule = create_enabled_rule();
1305 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307 let result = rule.check(&ctx).unwrap();
1308
1309 assert!(result.is_empty());
1311 }
1312
1313 #[test]
1314 fn test_json_arrays() {
1315 let rule = create_enabled_rule();
1316 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1317 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1318 let result = rule.check(&ctx).unwrap();
1319
1320 assert_eq!(result.len(), 1);
1322 }
1323
1324 #[test]
1325 fn test_fix_preserves_content_after_frontmatter() {
1326 let rule = create_enabled_rule();
1327 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1329 let fixed = rule.fix(&ctx).unwrap();
1330
1331 assert!(fixed.contains("# Heading"));
1333 assert!(fixed.contains("Paragraph 1."));
1334 assert!(fixed.contains("- List item"));
1335 assert!(fixed.contains("- Another item"));
1336 }
1337
1338 #[test]
1339 fn test_fix_yaml_produces_valid_yaml() {
1340 let rule = create_enabled_rule();
1341 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1343 let fixed = rule.fix(&ctx).unwrap();
1344
1345 let lines: Vec<&str> = fixed.lines().collect();
1348 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1349 let fm_content: String = lines[1..fm_end].join("\n");
1350
1351 let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&fm_content);
1353 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1354 }
1355
1356 #[test]
1357 fn test_fix_toml_produces_valid_toml() {
1358 let rule = create_enabled_rule();
1359 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1360 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1361 let fixed = rule.fix(&ctx).unwrap();
1362
1363 let lines: Vec<&str> = fixed.lines().collect();
1365 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1366 let fm_content: String = lines[1..fm_end].join("\n");
1367
1368 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1370 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1371 }
1372
1373 #[test]
1374 fn test_fix_json_produces_valid_json() {
1375 let rule = create_enabled_rule();
1376 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1378 let fixed = rule.fix(&ctx).unwrap();
1379
1380 let json_end = fixed.find("\n\n").unwrap();
1382 let json_content = &fixed[..json_end];
1383
1384 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1386 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1387 }
1388
1389 #[test]
1390 fn test_many_keys_performance() {
1391 let rule = create_enabled_rule();
1392 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1394 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1396
1397 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1398 let result = rule.check(&ctx).unwrap();
1399
1400 assert_eq!(result.len(), 1);
1402 }
1403
1404 #[test]
1405 fn test_yaml_empty_value() {
1406 let rule = create_enabled_rule();
1407 let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1409 let result = rule.check(&ctx).unwrap();
1410
1411 assert_eq!(result.len(), 1);
1413 }
1414
1415 #[test]
1416 fn test_yaml_null_value() {
1417 let rule = create_enabled_rule();
1418 let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1419 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1420 let result = rule.check(&ctx).unwrap();
1421
1422 assert_eq!(result.len(), 1);
1423 }
1424
1425 #[test]
1426 fn test_yaml_boolean_values() {
1427 let rule = create_enabled_rule();
1428 let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1430 let result = rule.check(&ctx).unwrap();
1431
1432 assert_eq!(result.len(), 1);
1434 }
1435
1436 #[test]
1437 fn test_toml_boolean_values() {
1438 let rule = create_enabled_rule();
1439 let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1441 let result = rule.check(&ctx).unwrap();
1442
1443 assert_eq!(result.len(), 1);
1444 }
1445
1446 #[test]
1447 fn test_yaml_list_at_top_level() {
1448 let rule = create_enabled_rule();
1449 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1450 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1451 let result = rule.check(&ctx).unwrap();
1452
1453 assert_eq!(result.len(), 1);
1455 }
1456
1457 #[test]
1458 fn test_three_keys_all_orderings() {
1459 let rule = create_enabled_rule();
1460
1461 let orderings = [
1463 ("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), ];
1470
1471 for (name, content, should_pass) in orderings {
1472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1473 let result = rule.check(&ctx).unwrap();
1474 assert_eq!(
1475 result.is_empty(),
1476 should_pass,
1477 "Ordering {name} should {} pass",
1478 if should_pass { "" } else { "not" }
1479 );
1480 }
1481 }
1482
1483 #[test]
1484 fn test_crlf_line_endings() {
1485 let rule = create_enabled_rule();
1486 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1488 let result = rule.check(&ctx).unwrap();
1489
1490 assert_eq!(result.len(), 1);
1492 }
1493
1494 #[test]
1495 fn test_json_escaped_quotes_in_keys() {
1496 let rule = create_enabled_rule();
1497 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1499 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1500 let result = rule.check(&ctx).unwrap();
1501
1502 assert_eq!(result.len(), 1);
1504 }
1505
1506 #[test]
1509 fn test_warning_fix_yaml_sorts_keys() {
1510 let rule = create_enabled_rule();
1511 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1512 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1513 let warnings = rule.check(&ctx).unwrap();
1514
1515 assert_eq!(warnings.len(), 1);
1516 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1517
1518 let fix = warnings[0].fix.as_ref().unwrap();
1519 assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1520
1521 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1523
1524 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1526 let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1527 assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1528 }
1529
1530 #[test]
1531 fn test_warning_fix_preserves_yaml_list_indentation() {
1532 let rule = create_enabled_rule();
1533 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1534 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535 let warnings = rule.check(&ctx).unwrap();
1536
1537 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1538
1539 assert!(
1541 fixed.contains(" - hello"),
1542 "List indentation should be preserved: {fixed}"
1543 );
1544 assert!(
1545 fixed.contains(" - world"),
1546 "List indentation should be preserved: {fixed}"
1547 );
1548 }
1549
1550 #[test]
1551 fn test_warning_fix_preserves_nested_object_indentation() {
1552 let rule = create_enabled_rule();
1553 let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
1554 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1555 let warnings = rule.check(&ctx).unwrap();
1556
1557 assert_eq!(warnings.len(), 1);
1558 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1559
1560 let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1562 let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1563 assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1564
1565 assert!(
1567 fixed.contains(" nested_key: nested_value"),
1568 "Nested object indentation should be preserved: {fixed}"
1569 );
1570 assert!(
1571 fixed.contains(" another: 123"),
1572 "Nested object indentation should be preserved: {fixed}"
1573 );
1574 }
1575
1576 #[test]
1577 fn test_warning_fix_preserves_deeply_nested_structure() {
1578 let rule = create_enabled_rule();
1579 let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
1580 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1581 let warnings = rule.check(&ctx).unwrap();
1582
1583 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1584
1585 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1587 let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1588 assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1589
1590 assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
1592 assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
1593 assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
1594 assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
1595 }
1596
1597 #[test]
1598 fn test_warning_fix_toml_sorts_keys() {
1599 let rule = create_enabled_rule();
1600 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1602 let warnings = rule.check(&ctx).unwrap();
1603
1604 assert_eq!(warnings.len(), 1);
1605 assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1606
1607 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1608
1609 let author_pos = fixed.find("author").expect("author should exist");
1611 let title_pos = fixed.find("title").expect("title should exist");
1612 assert!(author_pos < title_pos, "author should come before title");
1613 }
1614
1615 #[test]
1616 fn test_warning_fix_json_sorts_keys() {
1617 let rule = create_enabled_rule();
1618 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620 let warnings = rule.check(&ctx).unwrap();
1621
1622 assert_eq!(warnings.len(), 1);
1623 assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1624
1625 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1626
1627 let author_pos = fixed.find("author").expect("author should exist");
1629 let title_pos = fixed.find("title").expect("title should exist");
1630 assert!(author_pos < title_pos, "author should come before title");
1631 }
1632
1633 #[test]
1634 fn test_warning_fix_no_fix_when_comments_present() {
1635 let rule = create_enabled_rule();
1636 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1637 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1638 let warnings = rule.check(&ctx).unwrap();
1639
1640 assert_eq!(warnings.len(), 1);
1641 assert!(
1642 warnings[0].fix.is_none(),
1643 "Warning should NOT have a fix when comments are present"
1644 );
1645 assert!(
1646 warnings[0].message.contains("auto-fix unavailable"),
1647 "Message should indicate auto-fix is unavailable"
1648 );
1649 }
1650
1651 #[test]
1652 fn test_warning_fix_preserves_content_after_frontmatter() {
1653 let rule = create_enabled_rule();
1654 let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1655 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1656 let warnings = rule.check(&ctx).unwrap();
1657
1658 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1659
1660 assert!(fixed.contains("# Heading"), "Heading should be preserved");
1662 assert!(
1663 fixed.contains("Paragraph with content."),
1664 "Paragraph should be preserved"
1665 );
1666 assert!(fixed.contains("- List item"), "List item should be preserved");
1667 }
1668
1669 #[test]
1670 fn test_warning_fix_idempotent() {
1671 let rule = create_enabled_rule();
1672 let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1673 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1674 let warnings = rule.check(&ctx).unwrap();
1675
1676 let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1677
1678 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1680 let warnings2 = rule.check(&ctx2).unwrap();
1681
1682 assert!(
1683 warnings2.is_empty(),
1684 "After fixing, no more warnings should be produced"
1685 );
1686 }
1687
1688 #[test]
1689 fn test_warning_fix_preserves_multiline_block_literal() {
1690 let rule = create_enabled_rule();
1691 let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
1692 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1693 let warnings = rule.check(&ctx).unwrap();
1694
1695 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1696
1697 assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1699 assert!(
1700 fixed.contains(" Line 1 of block"),
1701 "Block literal line 1 should be preserved with indent"
1702 );
1703 assert!(
1704 fixed.contains(" Line 2 of block"),
1705 "Block literal line 2 should be preserved with indent"
1706 );
1707 }
1708
1709 #[test]
1710 fn test_warning_fix_preserves_folded_string() {
1711 let rule = create_enabled_rule();
1712 let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\n";
1713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1714 let warnings = rule.check(&ctx).unwrap();
1715
1716 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1717
1718 assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1720 assert!(
1721 fixed.contains(" Folded line 1"),
1722 "Folded line 1 should be preserved with indent"
1723 );
1724 assert!(
1725 fixed.contains(" Folded line 2"),
1726 "Folded line 2 should be preserved with indent"
1727 );
1728 }
1729
1730 #[test]
1731 fn test_warning_fix_preserves_4_space_indentation() {
1732 let rule = create_enabled_rule();
1733 let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
1735 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1736 let warnings = rule.check(&ctx).unwrap();
1737
1738 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1739
1740 assert!(
1742 fixed.contains(" nested: with_4_spaces"),
1743 "4-space indentation should be preserved: {fixed}"
1744 );
1745 assert!(
1746 fixed.contains(" another: value"),
1747 "4-space indentation should be preserved: {fixed}"
1748 );
1749 }
1750
1751 #[test]
1752 fn test_warning_fix_preserves_tab_indentation() {
1753 let rule = create_enabled_rule();
1754 let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1756 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1757 let warnings = rule.check(&ctx).unwrap();
1758
1759 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1760
1761 assert!(
1763 fixed.contains("\tnested: with_tab"),
1764 "Tab indentation should be preserved: {fixed}"
1765 );
1766 assert!(
1767 fixed.contains("\tanother: value"),
1768 "Tab indentation should be preserved: {fixed}"
1769 );
1770 }
1771
1772 #[test]
1773 fn test_warning_fix_preserves_inline_list() {
1774 let rule = create_enabled_rule();
1775 let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1778 let warnings = rule.check(&ctx).unwrap();
1779
1780 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1781
1782 assert!(
1784 fixed.contains("aaa: [one, two, three]"),
1785 "Inline list should be preserved exactly: {fixed}"
1786 );
1787 }
1788
1789 #[test]
1790 fn test_warning_fix_preserves_quoted_strings() {
1791 let rule = create_enabled_rule();
1792 let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1794 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1795 let warnings = rule.check(&ctx).unwrap();
1796
1797 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1798
1799 assert!(
1801 fixed.contains("aaa: \"value with: colon\""),
1802 "Double-quoted string should be preserved: {fixed}"
1803 );
1804 assert!(
1805 fixed.contains("bbb: 'single quotes'"),
1806 "Single-quoted string should be preserved: {fixed}"
1807 );
1808 }
1809
1810 #[test]
1813 fn test_yaml_custom_key_order_sorted() {
1814 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1816 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1817 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1818 let result = rule.check(&ctx).unwrap();
1819
1820 assert!(result.is_empty());
1822 }
1823
1824 #[test]
1825 fn test_yaml_custom_key_order_unsorted() {
1826 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1828 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1830 let result = rule.check(&ctx).unwrap();
1831
1832 assert_eq!(result.len(), 1);
1833 assert!(result[0].message.contains("'date' should come before 'author'"));
1835 }
1836
1837 #[test]
1838 fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1839 let rule = create_rule_with_key_order(vec!["title"]);
1841 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1842 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1843 let result = rule.check(&ctx).unwrap();
1844
1845 assert!(result.is_empty());
1848 }
1849
1850 #[test]
1851 fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1852 let rule = create_rule_with_key_order(vec!["title"]);
1854 let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1855 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1856 let result = rule.check(&ctx).unwrap();
1857
1858 assert_eq!(result.len(), 1);
1860 assert!(result[0].message.contains("'author' should come before 'zebra'"));
1861 }
1862
1863 #[test]
1864 fn test_yaml_custom_key_order_fix() {
1865 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1866 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1867 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1868 let fixed = rule.fix(&ctx).unwrap();
1869
1870 let title_pos = fixed.find("title:").unwrap();
1872 let date_pos = fixed.find("date:").unwrap();
1873 let author_pos = fixed.find("author:").unwrap();
1874 assert!(
1875 title_pos < date_pos && date_pos < author_pos,
1876 "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1877 );
1878 }
1879
1880 #[test]
1881 fn test_yaml_custom_key_order_fix_with_unlisted() {
1882 let rule = create_rule_with_key_order(vec!["title", "author"]);
1884 let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1885 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1886 let fixed = rule.fix(&ctx).unwrap();
1887
1888 let title_pos = fixed.find("title:").unwrap();
1890 let author_pos = fixed.find("author:").unwrap();
1891 let aardvark_pos = fixed.find("aardvark:").unwrap();
1892 let zebra_pos = fixed.find("zebra:").unwrap();
1893
1894 assert!(
1895 title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1896 "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1897 );
1898 }
1899
1900 #[test]
1901 fn test_toml_custom_key_order_sorted() {
1902 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1903 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1904 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1905 let result = rule.check(&ctx).unwrap();
1906
1907 assert!(result.is_empty());
1908 }
1909
1910 #[test]
1911 fn test_toml_custom_key_order_unsorted() {
1912 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1913 let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1914 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1915 let result = rule.check(&ctx).unwrap();
1916
1917 assert_eq!(result.len(), 1);
1918 assert!(result[0].message.contains("TOML"));
1919 }
1920
1921 #[test]
1922 fn test_json_custom_key_order_sorted() {
1923 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1924 let content = "{\n \"title\": \"Test\",\n \"date\": \"2024-01-01\",\n \"author\": \"John\"\n}\n\n# Heading";
1925 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926 let result = rule.check(&ctx).unwrap();
1927
1928 assert!(result.is_empty());
1929 }
1930
1931 #[test]
1932 fn test_json_custom_key_order_unsorted() {
1933 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1934 let content = "{\n \"author\": \"John\",\n \"title\": \"Test\",\n \"date\": \"2024-01-01\"\n}\n\n# Heading";
1935 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936 let result = rule.check(&ctx).unwrap();
1937
1938 assert_eq!(result.len(), 1);
1939 assert!(result[0].message.contains("JSON"));
1940 }
1941
1942 #[test]
1943 fn test_key_order_case_insensitive_match() {
1944 let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1946 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1948 let result = rule.check(&ctx).unwrap();
1949
1950 assert!(result.is_empty());
1952 }
1953
1954 #[test]
1955 fn test_key_order_partial_match() {
1956 let rule = create_rule_with_key_order(vec!["title"]);
1958 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1959 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1960 let result = rule.check(&ctx).unwrap();
1961
1962 assert_eq!(result.len(), 1);
1973 assert!(result[0].message.contains("'author' should come before 'date'"));
1974 }
1975
1976 #[test]
1979 fn test_key_order_empty_array_falls_back_to_alphabetical() {
1980 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1982 enabled: true,
1983 key_order: Some(vec![]),
1984 ..Default::default()
1985 });
1986 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1987 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1988 let result = rule.check(&ctx).unwrap();
1989
1990 assert_eq!(result.len(), 1);
1993 assert!(result[0].message.contains("'author' should come before 'title'"));
1994 }
1995
1996 #[test]
1997 fn test_key_order_single_key() {
1998 let rule = create_rule_with_key_order(vec!["title"]);
2000 let content = "---\ntitle: Test\n---\n\n# Heading";
2001 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2002 let result = rule.check(&ctx).unwrap();
2003
2004 assert!(result.is_empty());
2005 }
2006
2007 #[test]
2008 fn test_key_order_all_keys_specified() {
2009 let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
2011 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
2012 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2013 let result = rule.check(&ctx).unwrap();
2014
2015 assert!(result.is_empty());
2016 }
2017
2018 #[test]
2019 fn test_key_order_no_keys_match() {
2020 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
2022 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
2023 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2024 let result = rule.check(&ctx).unwrap();
2025
2026 assert!(result.is_empty());
2029 }
2030
2031 #[test]
2032 fn test_key_order_no_keys_match_unsorted() {
2033 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
2035 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
2036 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2037 let result = rule.check(&ctx).unwrap();
2038
2039 assert_eq!(result.len(), 1);
2042 }
2043
2044 #[test]
2045 fn test_key_order_duplicate_keys_in_config() {
2046 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2048 enabled: true,
2049 key_order: Some(vec![
2050 "title".to_string(),
2051 "author".to_string(),
2052 "title".to_string(), ]),
2054 ..Default::default()
2055 });
2056 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2058 let result = rule.check(&ctx).unwrap();
2059
2060 assert!(result.is_empty());
2062 }
2063
2064 #[test]
2065 fn test_key_order_with_comments_still_skips_fix() {
2066 let rule = create_rule_with_key_order(vec!["title", "author"]);
2068 let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
2069 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2070 let result = rule.check(&ctx).unwrap();
2071
2072 assert_eq!(result.len(), 1);
2074 assert!(result[0].message.contains("auto-fix unavailable"));
2075 assert!(result[0].fix.is_none());
2076 }
2077
2078 #[test]
2079 fn test_toml_custom_key_order_fix() {
2080 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
2081 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
2082 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2083 let fixed = rule.fix(&ctx).unwrap();
2084
2085 let title_pos = fixed.find("title").unwrap();
2087 let date_pos = fixed.find("date").unwrap();
2088 let author_pos = fixed.find("author").unwrap();
2089 assert!(
2090 title_pos < date_pos && date_pos < author_pos,
2091 "Fixed TOML should have keys in custom order. Got:\n{fixed}"
2092 );
2093 }
2094
2095 #[test]
2096 fn test_json_custom_key_order_fix() {
2097 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
2098 let content = "{\n \"author\": \"John\",\n \"date\": \"2024-01-01\",\n \"title\": \"Test\"\n}\n\n# Heading";
2099 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2100 let fixed = rule.fix(&ctx).unwrap();
2101
2102 let title_pos = fixed.find("\"title\"").unwrap();
2104 let date_pos = fixed.find("\"date\"").unwrap();
2105 let author_pos = fixed.find("\"author\"").unwrap();
2106 assert!(
2107 title_pos < date_pos && date_pos < author_pos,
2108 "Fixed JSON should have keys in custom order. Got:\n{fixed}"
2109 );
2110 }
2111
2112 #[test]
2113 fn test_key_order_unicode_keys() {
2114 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2116 enabled: true,
2117 key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
2118 ..Default::default()
2119 });
2120 let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
2121 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2122 let result = rule.check(&ctx).unwrap();
2123
2124 assert!(result.is_empty());
2126 }
2127
2128 #[test]
2129 fn test_key_order_mixed_specified_and_unlisted_boundary() {
2130 let rule = create_rule_with_key_order(vec!["z_last_specified"]);
2132 let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
2133 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2134 let result = rule.check(&ctx).unwrap();
2135
2136 assert!(result.is_empty());
2139 }
2140
2141 #[test]
2142 fn test_key_order_fix_preserves_values() {
2143 let rule = create_rule_with_key_order(vec!["title", "tags"]);
2145 let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n\n# Heading";
2146 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2147 let fixed = rule.fix(&ctx).unwrap();
2148
2149 let title_pos = fixed.find("title:").unwrap();
2151 let tags_pos = fixed.find("tags:").unwrap();
2152 assert!(title_pos < tags_pos, "title should come before tags");
2153
2154 assert!(fixed.contains("- rust"), "List items should be preserved");
2156 assert!(fixed.contains("- markdown"), "List items should be preserved");
2157 }
2158
2159 #[test]
2160 fn test_key_order_idempotent_fix() {
2161 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
2163 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
2164 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2165
2166 let fixed_once = rule.fix(&ctx).unwrap();
2167 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2168 let fixed_twice = rule.fix(&ctx2).unwrap();
2169
2170 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2171 }
2172
2173 #[test]
2174 fn test_key_order_respects_later_position_over_alphabetical() {
2175 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2177 let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
2178 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2179 let result = rule.check(&ctx).unwrap();
2180
2181 assert!(result.is_empty());
2183 }
2184
2185 #[test]
2188 fn test_json_braces_in_string_values_extracts_all_keys() {
2189 let rule = create_enabled_rule();
2193 let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
2194 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2195 let result = rule.check(&ctx).unwrap();
2196
2197 assert!(
2199 result.is_empty(),
2200 "All keys should be extracted and recognized as sorted. Got: {result:?}"
2201 );
2202 }
2203
2204 #[test]
2205 fn test_json_braces_in_string_key_after_brace_value_detected() {
2206 let rule = create_enabled_rule();
2208 let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2211 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2212 let result = rule.check(&ctx).unwrap();
2213
2214 assert_eq!(
2217 result.len(),
2218 1,
2219 "Should detect unsorted keys after brace-containing string value"
2220 );
2221 assert!(
2222 result[0].message.contains("'author' should come before 'description'"),
2223 "Should report author before description. Got: {}",
2224 result[0].message
2225 );
2226 }
2227
2228 #[test]
2229 fn test_json_brackets_in_string_values() {
2230 let rule = create_enabled_rule();
2232 let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2234 let result = rule.check(&ctx).unwrap();
2235
2236 assert_eq!(
2238 result.len(),
2239 1,
2240 "Should detect unsorted keys despite brackets in string values"
2241 );
2242 assert!(
2243 result[0].message.contains("'author' should come before 'description'"),
2244 "Got: {}",
2245 result[0].message
2246 );
2247 }
2248
2249 #[test]
2250 fn test_json_escaped_quotes_in_values() {
2251 let rule = create_enabled_rule();
2253 let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2254 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2255 let result = rule.check(&ctx).unwrap();
2256
2257 assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
2259 assert!(
2260 result[0].message.contains("'author' should come before 'title'"),
2261 "Got: {}",
2262 result[0].message
2263 );
2264 }
2265
2266 #[test]
2267 fn test_json_multiple_braces_in_string() {
2268 let rule = create_enabled_rule();
2270 let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2271 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2272 let result = rule.check(&ctx).unwrap();
2273
2274 assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
2276 assert!(
2277 result[0].message.contains("'author' should come before 'pattern'"),
2278 "Got: {}",
2279 result[0].message
2280 );
2281 }
2282
2283 #[test]
2284 fn test_key_order_detects_wrong_custom_order() {
2285 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2287 let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
2288 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2289 let result = rule.check(&ctx).unwrap();
2290
2291 assert_eq!(result.len(), 1);
2292 assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
2293 }
2294
2295 #[test]
2298 fn test_required_keys_yaml_missing_key_warns_without_fix() {
2299 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2300 let content = "---\ntitle: Test\n---\n\n# Heading";
2301 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2302 let result = rule.check(&ctx).unwrap();
2303
2304 assert_eq!(result.len(), 1);
2305 assert!(result[0].message.contains("missing required key 'date'"));
2306 assert!(result[0].message.contains("YAML"));
2307 assert!(result[0].fix.is_none(), "missing keys must not be auto-fixable");
2308 assert_eq!(result[0].line, 1);
2310 assert_eq!(result[0].column, 1);
2311 assert_eq!(result[0].end_column, 4);
2312 }
2313
2314 #[test]
2315 fn test_required_keys_all_present_no_warning() {
2316 let rule = create_rule_with_required_keys(vec!["author", "title"]);
2317 let content = "---\nauthor: John\ntitle: Test\n---\n\n# Heading";
2318 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2319 let result = rule.check(&ctx).unwrap();
2320
2321 assert!(result.is_empty());
2322 }
2323
2324 #[test]
2325 fn test_required_keys_one_warning_per_missing_key() {
2326 let rule = create_rule_with_required_keys(vec!["title", "date", "author"]);
2327 let content = "---\ntags: [a, b]\n---\n\n# Heading";
2328 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2329 let result = rule.check(&ctx).unwrap();
2330
2331 assert_eq!(result.len(), 3);
2332 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
2333 assert!(messages.iter().any(|m| m.contains("'title'")));
2334 assert!(messages.iter().any(|m| m.contains("'date'")));
2335 assert!(messages.iter().any(|m| m.contains("'author'")));
2336 }
2337
2338 #[test]
2339 fn test_required_keys_case_insensitive_match() {
2340 let rule = create_rule_with_required_keys(vec!["Title"]);
2342 let content = "---\ntitle: Test\n---\n\n# Heading";
2343 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2344 let result = rule.check(&ctx).unwrap();
2345
2346 assert!(result.is_empty());
2347 }
2348
2349 #[test]
2350 fn test_required_keys_missing_and_unsorted_both_reported() {
2351 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2352 enabled: true,
2353 required_keys: vec!["date".to_string()],
2354 ..Default::default()
2355 });
2356 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2357 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2358 let result = rule.check(&ctx).unwrap();
2359
2360 assert_eq!(result.len(), 2);
2361 assert!(result[0].message.contains("missing required key 'date'"));
2362 assert!(result[1].message.contains("'author' should come before 'title'"));
2363 }
2364
2365 #[test]
2366 fn test_required_keys_toml_missing_key() {
2367 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2368 let content = "+++\ntitle = \"Test\"\n+++\n\n# Heading";
2369 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2370 let result = rule.check(&ctx).unwrap();
2371
2372 assert_eq!(result.len(), 1);
2373 assert!(
2374 result[0]
2375 .message
2376 .contains("TOML frontmatter is missing required key 'date'")
2377 );
2378 assert!(result[0].fix.is_none());
2379 }
2380
2381 #[test]
2382 fn test_required_keys_json_missing_key() {
2383 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2384 let content = "{\n\"title\": \"Test\"\n}\n\n# Heading";
2385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2386 let result = rule.check(&ctx).unwrap();
2387
2388 assert_eq!(result.len(), 1);
2389 assert!(
2390 result[0]
2391 .message
2392 .contains("JSON frontmatter is missing required key 'date'")
2393 );
2394 assert_eq!(result[0].end_column, 2);
2396 }
2397
2398 #[test]
2399 fn test_required_keys_no_frontmatter_no_warning() {
2400 let rule = create_rule_with_required_keys(vec!["title"]);
2403 let content = "# Heading\n\nContent.";
2404 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2405 let result = rule.check(&ctx).unwrap();
2406
2407 assert!(result.is_empty());
2408 }
2409
2410 #[test]
2411 fn test_required_keys_empty_frontmatter_warns() {
2412 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2414 let content = "---\n---\n\n# Heading";
2415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2416 let result = rule.check(&ctx).unwrap();
2417
2418 assert_eq!(result.len(), 2);
2419 assert!(result.iter().all(|w| w.message.contains("missing required key")));
2420 }
2421
2422 #[test]
2423 fn test_required_keys_nested_key_does_not_satisfy() {
2424 let rule = create_rule_with_required_keys(vec!["title"]);
2426 let content = "---\nmeta:\n title: Nested\n---\n\n# Heading";
2427 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2428 let result = rule.check(&ctx).unwrap();
2429
2430 assert_eq!(result.len(), 1);
2431 assert!(result[0].message.contains("missing required key 'title'"));
2432 }
2433
2434 #[test]
2435 fn test_required_keys_quoted_yaml_key_satisfies() {
2436 let rule = create_rule_with_required_keys(vec!["title"]);
2438 let content = "---\n\"title\": Test\n---\n\n# Heading";
2439 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2440 let result = rule.check(&ctx).unwrap();
2441
2442 assert!(result.is_empty());
2443 }
2444
2445 #[test]
2446 fn test_required_keys_fix_does_not_insert_keys() {
2447 let rule = create_rule_with_required_keys(vec!["date"]);
2450 let content = "---\nauthor: John\ntitle: Test\n---\n\n# Heading";
2451 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2452 let fixed = rule.fix(&ctx).unwrap();
2453
2454 assert_eq!(fixed, content);
2455 }
2456
2457 #[test]
2458 fn test_required_keys_with_key_order_subset() {
2459 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2462 enabled: true,
2463 key_order: Some(vec![
2464 "title".to_string(),
2465 "date".to_string(),
2466 "author".to_string(),
2467 "tags".to_string(),
2468 ]),
2469 required_keys: vec!["title".to_string(), "date".to_string()],
2470 });
2471
2472 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2474 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2475 let result = rule.check(&ctx).unwrap();
2476 assert_eq!(result.len(), 1);
2477 assert!(result[0].message.contains("missing required key 'date'"));
2478
2479 let content = "---\ntitle: Test\ndate: 2024-01-01\ntags: [a]\n---\n\n# Heading";
2481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2482 let result = rule.check(&ctx).unwrap();
2483 assert!(result.is_empty());
2484 }
2485
2486 #[test]
2487 fn test_required_keys_unsorted_fix_still_applies_without_inserting() {
2488 let rule = create_rule_with_required_keys(vec!["date"]);
2491 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2492 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2493 let fixed = rule.fix(&ctx).unwrap();
2494
2495 let author_pos = fixed.find("author:").unwrap();
2496 let title_pos = fixed.find("title:").unwrap();
2497 assert!(author_pos < title_pos, "sort fix must still apply");
2498 assert!(!fixed.contains("date"), "fix must not insert the missing key");
2499
2500 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2501 let result = rule.check(&ctx2).unwrap();
2502 assert_eq!(result.len(), 1);
2503 assert!(result[0].message.contains("missing required key 'date'"));
2504 }
2505
2506 #[test]
2507 fn test_required_keys_warning_spans_the_frontmatter_block() {
2508 let rule = create_rule_with_required_keys(vec!["date"]);
2512 let content = "---\ntitle: Test\n---\n\n# Heading";
2513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2514 let result = rule.check(&ctx).unwrap();
2515
2516 assert_eq!(result.len(), 1);
2517 assert_eq!(result[0].line, 1);
2518 assert_eq!(result[0].column, 1);
2519 assert_eq!(result[0].end_line, 3, "span must reach the closing fence line");
2520 assert_eq!(result[0].end_column, 4);
2521 }
2522
2523 #[test]
2524 fn test_required_keys_suppressed_by_inline_disable_in_frontmatter() {
2525 let rule = create_rule_with_required_keys(vec!["date"]);
2530 let content = "---\n# <!-- rumdl-disable MD072 -->\ntitle: Test\n---\n\n# Heading\n";
2531 let warnings = crate::lint(
2532 content,
2533 &[Box::new(rule) as Box<dyn Rule>],
2534 false,
2535 crate::config::MarkdownFlavor::Standard,
2536 None,
2537 None,
2538 )
2539 .unwrap();
2540
2541 assert!(
2542 warnings.is_empty(),
2543 "inline disable inside the frontmatter must suppress missing-key warnings, got: {warnings:?}"
2544 );
2545 }
2546
2547 #[test]
2548 fn test_required_keys_reported_through_lint_without_disable() {
2549 let rule = create_rule_with_required_keys(vec!["date"]);
2552 let content = "---\ntitle: Test\n---\n\n# Heading\n";
2553 let warnings = crate::lint(
2554 content,
2555 &[Box::new(rule) as Box<dyn Rule>],
2556 false,
2557 crate::config::MarkdownFlavor::Standard,
2558 None,
2559 None,
2560 )
2561 .unwrap();
2562
2563 assert_eq!(warnings.len(), 1);
2564 assert!(warnings[0].message.contains("missing required key 'date'"));
2565 }
2566
2567 #[test]
2568 fn test_required_keys_quoted_toml_key_satisfies() {
2569 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2571 let content = "+++\n\"date\" = \"2024-01-01\"\n'title' = \"Test\"\n+++\n\n# Heading";
2572 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2573 let result = rule.check(&ctx).unwrap();
2574
2575 assert!(
2576 result.is_empty(),
2577 "quoted TOML keys must satisfy required-keys, got: {result:?}"
2578 );
2579 }
2580
2581 #[test]
2582 fn test_toml_quoted_keys_sort_by_content() {
2583 let rule = create_enabled_rule();
2587 let content = "+++\n\"zebra\" = 1\napple = 2\n+++\n\n# Heading";
2588 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2589 let result = rule.check(&ctx).unwrap();
2590
2591 assert_eq!(result.len(), 1, "quoted TOML key out of order must be flagged");
2592 assert!(result[0].message.contains("'apple' should come before 'zebra'"));
2593 }
2594
2595 #[test]
2596 fn test_toml_quoted_key_warning_span_covers_quotes() {
2597 let rule = create_enabled_rule();
2600 let content = "+++\nbanana = 1\n\"apple\" = 2\n+++\n";
2601 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2602 let result = rule.check(&ctx).unwrap();
2603
2604 assert_eq!(result.len(), 1);
2605 let w = &result[0];
2606 assert_eq!(w.line, 3);
2607 assert_eq!(w.column, 1);
2608 assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
2610 }
2611
2612 #[test]
2613 fn test_required_keys_json_multiple_keys_on_one_line() {
2614 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2618 let content = "{\n\"title\": \"Test\", \"date\": \"2024-01-01\"\n}\n\n# Heading";
2619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2620 let result = rule.check(&ctx).unwrap();
2621
2622 assert!(
2623 result.is_empty(),
2624 "all keys on one JSON line must satisfy required-keys, got: {result:?}"
2625 );
2626 }
2627
2628 #[test]
2629 fn test_required_keys_json_multiple_keys_on_one_line_missing_still_reported() {
2630 let rule = create_rule_with_required_keys(vec!["title", "date", "author"]);
2632 let content = "{\n\"title\": \"Test\", \"date\": \"2024-01-01\"\n}\n\n# Heading";
2633 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2634 let result = rule.check(&ctx).unwrap();
2635
2636 assert_eq!(result.len(), 1);
2637 assert!(result[0].message.contains("missing required key 'author'"));
2638 }
2639
2640 #[test]
2641 fn test_required_keys_json_invalid_falls_back_to_line_based_keys() {
2642 let rule = create_rule_with_required_keys(vec!["title"]);
2645 let content = "{\n\"title\": unquoted-invalid\n}\n\n# Heading";
2646 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2647 let result = rule.check(&ctx).unwrap();
2648
2649 assert!(
2650 result.is_empty(),
2651 "invalid JSON must fall back to line-based key extraction, got: {result:?}"
2652 );
2653 }
2654
2655 #[test]
2656 fn test_required_keys_toml_table_header_satisfies() {
2657 let rule = create_rule_with_required_keys(vec!["title", "taxonomies"]);
2661 let content = "+++\ntitle = \"Test\"\n\n[taxonomies]\ntags = [\"a\"]\n+++\n\n# Heading";
2662 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2663 let result = rule.check(&ctx).unwrap();
2664
2665 assert!(
2666 result.is_empty(),
2667 "a TOML table header must satisfy required-keys, got: {result:?}"
2668 );
2669 }
2670
2671 #[test]
2672 fn test_required_keys_toml_array_of_tables_satisfies() {
2673 let rule = create_rule_with_required_keys(vec!["authors"]);
2675 let content = "+++\ntitle = \"Test\"\n\n[[authors]]\nname = \"John\"\n+++\n\n# Heading";
2676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2677 let result = rule.check(&ctx).unwrap();
2678
2679 assert!(
2680 result.is_empty(),
2681 "a TOML array-of-tables header must satisfy required-keys, got: {result:?}"
2682 );
2683 }
2684
2685 #[test]
2686 fn test_required_keys_toml_dotted_table_header_satisfies_root() {
2687 let rule = create_rule_with_required_keys(vec!["params"]);
2689 let content = "+++\ntitle = \"Test\"\n\n[params.seo]\nnoindex = true\n+++\n\n# Heading";
2690 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2691 let result = rule.check(&ctx).unwrap();
2692
2693 assert!(
2694 result.is_empty(),
2695 "a dotted TOML table header must satisfy its root key, got: {result:?}"
2696 );
2697 }
2698
2699 #[test]
2700 fn test_required_keys_toml_missing_despite_other_tables() {
2701 let rule = create_rule_with_required_keys(vec!["date"]);
2703 let content = "+++\ntitle = \"Test\"\n\n[taxonomies]\ntags = [\"a\"]\n+++\n\n# Heading";
2704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2705 let result = rule.check(&ctx).unwrap();
2706
2707 assert_eq!(result.len(), 1);
2708 assert!(result[0].message.contains("missing required key 'date'"));
2709 }
2710
2711 #[test]
2712 fn test_required_keys_toml_dotted_assignment_satisfies_root() {
2713 let rule = create_rule_with_required_keys(vec!["params"]);
2715 let content = "+++\nparams.seo = true\ntitle = \"Test\"\n+++\n\n# Heading";
2716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2717 let result = rule.check(&ctx).unwrap();
2718
2719 assert!(
2720 result.is_empty(),
2721 "a dotted TOML assignment must satisfy its root key, got: {result:?}"
2722 );
2723 }
2724
2725 #[test]
2726 fn test_required_keys_toml_quoted_dotted_key_is_atomic() {
2727 let rule = create_rule_with_required_keys(vec!["a.b"]);
2729 let content = "+++\n\"a.b\" = 1\n+++\n\n# Heading";
2730 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2731 let result = rule.check(&ctx).unwrap();
2732 assert!(
2733 result.is_empty(),
2734 "quoted dotted key must match literally, got: {result:?}"
2735 );
2736
2737 let rule = create_rule_with_required_keys(vec!["a"]);
2738 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2739 let result = rule.check(&ctx).unwrap();
2740 assert_eq!(result.len(), 1, "quoted dotted key must NOT satisfy its first segment");
2741 assert!(result[0].message.contains("missing required key 'a'"));
2742 }
2743
2744 #[test]
2745 fn test_required_keys_toml_table_header_with_inline_comment() {
2746 let rule = create_rule_with_required_keys(vec!["taxonomies"]);
2748 let content = "+++\ntitle = \"Test\"\n\n[taxonomies] # used by Hugo\ntags = [\"a\"]\n+++\n\n# Heading";
2749 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2750 let result = rule.check(&ctx).unwrap();
2751
2752 assert!(
2753 result.is_empty(),
2754 "a table header with an inline comment must satisfy required-keys, got: {result:?}"
2755 );
2756 }
2757
2758 #[test]
2759 fn test_required_keys_toml_assignment_inside_table_does_not_satisfy() {
2760 let rule = create_rule_with_required_keys(vec!["date"]);
2762 let content = "+++\ntitle = \"Test\"\n\n[params]\ndate = \"2024-01-01\"\n+++\n\n# Heading";
2763 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2764 let result = rule.check(&ctx).unwrap();
2765
2766 assert_eq!(result.len(), 1);
2767 assert!(result[0].message.contains("missing required key 'date'"));
2768 }
2769
2770 #[test]
2771 fn test_required_keys_yaml_quoted_key_with_colon_satisfies() {
2772 let rule = create_rule_with_required_keys(vec!["og:title"]);
2775 let content = "---\n\"og:title\": My post\n---\n\n# Heading";
2776 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2777 let result = rule.check(&ctx).unwrap();
2778
2779 assert!(
2780 result.is_empty(),
2781 "a quoted YAML key containing a colon must satisfy required-keys, got: {result:?}"
2782 );
2783 }
2784
2785 #[test]
2786 fn test_yaml_quoted_key_with_colon_sorts_by_full_content() {
2787 let rule = create_enabled_rule();
2789 let content = "---\n\"og:title\": My post\nalpha: 1\n---\n\n# Heading";
2790 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2791 let result = rule.check(&ctx).unwrap();
2792
2793 assert_eq!(result.len(), 1);
2794 assert!(
2795 result[0].message.contains("'alpha' should come before 'og:title'"),
2796 "sorting must use the full quoted key, got: {}",
2797 result[0].message
2798 );
2799 }
2800
2801 #[test]
2802 fn test_required_keys_toml_quoted_key_with_equals_satisfies() {
2803 let rule = create_rule_with_required_keys(vec!["a=b"]);
2806 let content = "+++\n\"a=b\" = 1\n+++\n\n# Heading";
2807 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2808 let result = rule.check(&ctx).unwrap();
2809
2810 assert!(
2811 result.is_empty(),
2812 "a quoted TOML key containing '=' must satisfy required-keys, got: {result:?}"
2813 );
2814 }
2815}