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);
608}
609
610impl MD072FrontmatterKeySort {
611 fn preserve_trailing_newline(original: &str, mut result: String) -> String {
620 let wanted = original.len() - original.trim_end_matches('\n').len();
621 let have = result.len() - result.trim_end_matches('\n').len();
622 for _ in have..wanted {
623 result.push('\n');
624 }
625 result
626 }
627
628 fn fix_yaml(&self, content: &str, fm_end: usize) -> String {
629 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
630 if frontmatter_lines.is_empty() {
631 return content.to_string();
632 }
633
634 if Self::has_comments(&frontmatter_lines) {
636 return content.to_string();
637 }
638
639 let keys = Self::extract_yaml_keys(&frontmatter_lines);
640 let key_order = self.config.key_order.as_deref();
641 if Self::are_indexed_keys_sorted(&keys, key_order) {
642 return content.to_string();
643 }
644
645 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
648
649 for (i, (line_idx, key)) in keys.iter().enumerate() {
650 let start = *line_idx;
651 let end = if i + 1 < keys.len() {
652 keys[i + 1].0
653 } else {
654 frontmatter_lines.len()
655 };
656
657 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
658 key_blocks.push((key.clone(), block_lines));
659 }
660
661 Self::sort_keys_by_order(&mut key_blocks, key_order);
663
664 let content_lines: Vec<&str> = content.lines().collect();
666
667 let mut result = String::new();
668 result.push_str("---\n");
669 for (_, lines) in &key_blocks {
670 for line in lines {
671 result.push_str(line);
672 result.push('\n');
673 }
674 }
675 result.push_str("---");
676
677 if fm_end < content_lines.len() {
678 result.push('\n');
679 result.push_str(&content_lines[fm_end..].join("\n"));
680 }
681
682 Self::preserve_trailing_newline(content, result)
683 }
684
685 fn fix_toml(&self, content: &str, fm_end: usize) -> String {
686 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
687 if frontmatter_lines.is_empty() {
688 return content.to_string();
689 }
690
691 if Self::has_comments(&frontmatter_lines) {
693 return content.to_string();
694 }
695
696 let keys = Self::extract_toml_keys(&frontmatter_lines);
697 let key_order = self.config.key_order.as_deref();
698 if Self::are_indexed_keys_sorted(&keys, key_order) {
699 return content.to_string();
700 }
701
702 let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
705
706 for (i, (line_idx, key)) in keys.iter().enumerate() {
707 let start = *line_idx;
708 let end = if i + 1 < keys.len() {
709 keys[i + 1].0
710 } else {
711 frontmatter_lines.len()
712 };
713
714 let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
715 key_blocks.push((key.clone(), block_lines));
716 }
717
718 Self::sort_keys_by_order(&mut key_blocks, key_order);
720
721 let content_lines: Vec<&str> = content.lines().collect();
723
724 let mut result = String::new();
725 result.push_str("+++\n");
726 for (_, lines) in &key_blocks {
727 for line in lines {
728 result.push_str(line);
729 result.push('\n');
730 }
731 }
732 result.push_str("+++");
733
734 if fm_end < content_lines.len() {
735 result.push('\n');
736 result.push_str(&content_lines[fm_end..].join("\n"));
737 }
738
739 Self::preserve_trailing_newline(content, result)
740 }
741
742 fn fix_json(&self, content: &str, fm_end: usize) -> String {
743 let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
744 if frontmatter_lines.is_empty() {
745 return content.to_string();
746 }
747
748 let keys = Self::extract_json_keys(&frontmatter_lines);
749 let key_order = self.config.key_order.as_deref();
750
751 if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
752 return content.to_string();
753 }
754
755 let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
757
758 match serde_json::from_str::<serde_json::Value>(&json_content) {
760 Ok(serde_json::Value::Object(map)) => {
761 let mut sorted_map = serde_json::Map::new();
763 let mut keys: Vec<_> = map.keys().cloned().collect();
764 keys.sort_by(|a, b| {
765 let pos_a = Self::key_sort_position(a, key_order);
766 let pos_b = Self::key_sort_position(b, key_order);
767 pos_a.cmp(&pos_b)
768 });
769
770 for key in keys {
771 if let Some(value) = map.get(&key) {
772 sorted_map.insert(key, value.clone());
773 }
774 }
775
776 match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
777 Ok(sorted_json) => {
778 let lines: Vec<&str> = content.lines().collect();
779
780 let mut result = String::new();
783 result.push_str(&sorted_json);
784
785 if fm_end < lines.len() {
786 result.push('\n');
787 result.push_str(&lines[fm_end..].join("\n"));
788 }
789
790 Self::preserve_trailing_newline(content, result)
791 }
792 Err(_) => content.to_string(),
793 }
794 }
795 _ => content.to_string(),
796 }
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use crate::lint_context::LintContext;
804
805 fn create_enabled_rule() -> MD072FrontmatterKeySort {
807 MD072FrontmatterKeySort::from_config_struct(MD072Config {
808 enabled: true,
809 ..Default::default()
810 })
811 }
812
813 fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
815 MD072FrontmatterKeySort::from_config_struct(MD072Config {
816 enabled: true,
817 key_order: Some(keys.into_iter().map(String::from).collect()),
818 ..Default::default()
819 })
820 }
821
822 fn create_rule_with_required_keys(keys: Vec<&str>) -> MD072FrontmatterKeySort {
824 MD072FrontmatterKeySort::from_config_struct(MD072Config {
825 enabled: true,
826 required_keys: keys.into_iter().map(String::from).collect(),
827 ..Default::default()
828 })
829 }
830
831 #[test]
834 fn test_enabled_via_config() {
835 let rule = create_enabled_rule();
836 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
837 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
838 let result = rule.check(&ctx).unwrap();
839
840 assert_eq!(result.len(), 1);
842 }
843
844 #[test]
847 fn test_no_frontmatter() {
848 let rule = create_enabled_rule();
849 let content = "# Heading\n\nContent.";
850 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
851 let result = rule.check(&ctx).unwrap();
852
853 assert!(result.is_empty());
854 }
855
856 #[test]
857 fn test_yaml_sorted_keys() {
858 let rule = create_enabled_rule();
859 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
860 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861 let result = rule.check(&ctx).unwrap();
862
863 assert!(result.is_empty());
864 }
865
866 #[test]
867 fn test_yaml_unsorted_keys() {
868 let rule = create_enabled_rule();
869 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
870 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
871 let result = rule.check(&ctx).unwrap();
872
873 assert_eq!(result.len(), 1);
874 assert!(result[0].message.contains("YAML"));
875 assert!(result[0].message.contains("not sorted"));
876 assert!(result[0].message.contains("'author' should come before 'title'"));
878 }
879
880 #[test]
881 fn test_yaml_case_insensitive_sort() {
882 let rule = create_enabled_rule();
883 let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
884 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 let result = rule.check(&ctx).unwrap();
886
887 assert!(result.is_empty());
889 }
890
891 #[test]
892 fn test_yaml_fix_sorts_keys() {
893 let rule = create_enabled_rule();
894 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
895 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896 let fixed = rule.fix(&ctx).unwrap();
897
898 let author_pos = fixed.find("author:").unwrap();
900 let title_pos = fixed.find("title:").unwrap();
901 assert!(author_pos < title_pos);
902 }
903
904 #[test]
905 fn test_yaml_no_fix_with_comments() {
906 let rule = create_enabled_rule();
907 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
908 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
909 let result = rule.check(&ctx).unwrap();
910
911 assert_eq!(result.len(), 1);
912 assert!(result[0].message.contains("auto-fix unavailable"));
913 assert!(result[0].fix.is_none());
914
915 let fixed = rule.fix(&ctx).unwrap();
917 assert_eq!(fixed, content);
918 }
919
920 #[test]
921 fn test_yaml_single_key() {
922 let rule = create_enabled_rule();
923 let content = "---\ntitle: Test\n---\n\n# Heading";
924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
925 let result = rule.check(&ctx).unwrap();
926
927 assert!(result.is_empty());
929 }
930
931 #[test]
932 fn test_yaml_nested_keys_ignored() {
933 let rule = create_enabled_rule();
934 let content = "---\nauthor:\n name: John\n email: john@example.com\ntitle: Test\n---\n\n# Heading";
936 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937 let result = rule.check(&ctx).unwrap();
938
939 assert!(result.is_empty());
941 }
942
943 #[test]
944 fn test_yaml_fix_idempotent() {
945 let rule = create_enabled_rule();
946 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
947 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
948 let fixed_once = rule.fix(&ctx).unwrap();
949
950 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
951 let fixed_twice = rule.fix(&ctx2).unwrap();
952
953 assert_eq!(fixed_once, fixed_twice);
954 }
955
956 #[test]
957 fn test_yaml_fix_preserves_trailing_newline() {
958 let rule = create_enabled_rule();
959 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n";
961 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
962 let fixed = rule.fix(&ctx).unwrap();
963 assert!(
964 fixed.ends_with('\n'),
965 "trailing newline must be preserved, got {fixed:?}"
966 );
967
968 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
970 let fixed_twice = rule.fix(&ctx2).unwrap();
971 assert_eq!(fixed, fixed_twice);
972 }
973
974 #[test]
975 fn test_yaml_fix_whole_file_frontmatter_preserves_trailing_newline() {
976 let rule = create_enabled_rule();
977 let content = "---\ntitle: Test\nauthor: John\n---\n";
979 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
980 let fixed = rule.fix(&ctx).unwrap();
981 assert!(
982 fixed.ends_with('\n'),
983 "trailing newline must be preserved, got {fixed:?}"
984 );
985 }
986
987 #[test]
988 fn test_fix_preserves_a_run_of_trailing_newlines() {
989 let rule = create_enabled_rule();
994 let cases = [
995 ("yaml", "---\ntitle: Test\nauthor: John\n---\n\n# Heading"),
996 ("toml", "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading"),
997 ("json", "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading"),
998 ("yaml whole file", "---\ntitle: Test\nauthor: John\n---"),
999 ];
1000 for (label, body) in cases {
1001 for trailing in 0..4 {
1002 let content = format!("{body}{}", "\n".repeat(trailing));
1003 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1004 let fixed = rule.fix(&ctx).unwrap();
1005 assert_eq!(
1006 fixed.len() - fixed.trim_end_matches('\n').len(),
1007 trailing,
1008 "{label} with {trailing} trailing newline(s) must keep them, got {fixed:?}"
1009 );
1010 assert!(
1013 fixed.find("author").unwrap() < fixed.find("title").unwrap(),
1014 "{label} with {trailing} trailing newline(s) was not sorted, got {fixed:?}"
1015 );
1016 }
1017 }
1018 }
1019
1020 #[test]
1021 fn test_yaml_quoted_keys_sort_by_content() {
1022 let rule = create_enabled_rule();
1023 let content = "---\n\"zebra\": 1\napple: 2\n---\n\n# Heading";
1026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1027 let result = rule.check(&ctx).unwrap();
1028
1029 assert_eq!(result.len(), 1, "quoted key out of order must be flagged");
1030 assert!(result[0].message.contains("'apple' should come before 'zebra'"));
1031 }
1032
1033 #[test]
1034 fn test_yaml_quoted_key_warning_span_covers_quotes() {
1035 let rule = create_enabled_rule();
1036 let content = "---\nbanana: 1\n\"apple\": 2\n---\n";
1040 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1041 let result = rule.check(&ctx).unwrap();
1042
1043 assert_eq!(result.len(), 1);
1044 let w = &result[0];
1045 assert_eq!(w.line, 3);
1046 assert_eq!(w.column, 1);
1047 assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
1049 }
1050
1051 #[test]
1052 fn test_yaml_complex_values() {
1053 let rule = create_enabled_rule();
1054 let content =
1056 "---\nauthor: John Doe\ntags:\n - rust\n - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
1057 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1058 let result = rule.check(&ctx).unwrap();
1059
1060 assert!(result.is_empty());
1062 }
1063
1064 #[test]
1067 fn test_toml_sorted_keys() {
1068 let rule = create_enabled_rule();
1069 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1070 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071 let result = rule.check(&ctx).unwrap();
1072
1073 assert!(result.is_empty());
1074 }
1075
1076 #[test]
1077 fn test_toml_unsorted_keys() {
1078 let rule = create_enabled_rule();
1079 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
1080 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1081 let result = rule.check(&ctx).unwrap();
1082
1083 assert_eq!(result.len(), 1);
1084 assert!(result[0].message.contains("TOML"));
1085 assert!(result[0].message.contains("not sorted"));
1086 }
1087
1088 #[test]
1089 fn test_toml_fix_sorts_keys() {
1090 let rule = create_enabled_rule();
1091 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
1092 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1093 let fixed = rule.fix(&ctx).unwrap();
1094
1095 let author_pos = fixed.find("author").unwrap();
1097 let title_pos = fixed.find("title").unwrap();
1098 assert!(author_pos < title_pos);
1099 }
1100
1101 #[test]
1102 fn test_toml_no_fix_with_comments() {
1103 let rule = create_enabled_rule();
1104 let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
1105 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106 let result = rule.check(&ctx).unwrap();
1107
1108 assert_eq!(result.len(), 1);
1109 assert!(result[0].message.contains("auto-fix unavailable"));
1110
1111 let fixed = rule.fix(&ctx).unwrap();
1113 assert_eq!(fixed, content);
1114 }
1115
1116 #[test]
1119 fn test_json_sorted_keys() {
1120 let rule = create_enabled_rule();
1121 let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
1122 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1123 let result = rule.check(&ctx).unwrap();
1124
1125 assert!(result.is_empty());
1126 }
1127
1128 #[test]
1129 fn test_json_unsorted_keys() {
1130 let rule = create_enabled_rule();
1131 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1132 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1133 let result = rule.check(&ctx).unwrap();
1134
1135 assert_eq!(result.len(), 1);
1136 assert!(result[0].message.contains("JSON"));
1137 assert!(result[0].message.contains("not sorted"));
1138 }
1139
1140 #[test]
1141 fn test_json_fix_sorts_keys() {
1142 let rule = create_enabled_rule();
1143 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1144 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145 let fixed = rule.fix(&ctx).unwrap();
1146
1147 let author_pos = fixed.find("author").unwrap();
1149 let title_pos = fixed.find("title").unwrap();
1150 assert!(author_pos < title_pos);
1151 }
1152
1153 #[test]
1154 fn test_json_always_fixable() {
1155 let rule = create_enabled_rule();
1156 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1158 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159 let result = rule.check(&ctx).unwrap();
1160
1161 assert_eq!(result.len(), 1);
1162 assert!(result[0].fix.is_some()); assert!(!result[0].message.contains("Auto-fix unavailable"));
1164 }
1165
1166 #[test]
1169 fn test_empty_content() {
1170 let rule = create_enabled_rule();
1171 let content = "";
1172 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1173 let result = rule.check(&ctx).unwrap();
1174
1175 assert!(result.is_empty());
1176 }
1177
1178 #[test]
1179 fn test_empty_frontmatter() {
1180 let rule = create_enabled_rule();
1181 let content = "---\n---\n\n# Heading";
1182 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1183 let result = rule.check(&ctx).unwrap();
1184
1185 assert!(result.is_empty());
1186 }
1187
1188 #[test]
1189 fn test_toml_nested_tables_ignored() {
1190 let rule = create_enabled_rule();
1192 let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
1193 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1194 let result = rule.check(&ctx).unwrap();
1195
1196 assert_eq!(result.len(), 1);
1198 assert!(result[0].message.contains("'sort_by' should come before 'title'"));
1200 assert!(!result[0].message.contains("we_have_extra"));
1201 }
1202
1203 #[test]
1204 fn test_toml_nested_taxonomies_ignored() {
1205 let rule = create_enabled_rule();
1207 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
1208 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209 let result = rule.check(&ctx).unwrap();
1210
1211 assert_eq!(result.len(), 1);
1213 assert!(result[0].message.contains("'date' should come before 'title'"));
1215 assert!(!result[0].message.contains("categories"));
1216 assert!(!result[0].message.contains("tags"));
1217 }
1218
1219 #[test]
1222 fn test_yaml_unicode_keys() {
1223 let rule = create_enabled_rule();
1224 let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
1226 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1227 let result = rule.check(&ctx).unwrap();
1228
1229 assert_eq!(result.len(), 1);
1231 }
1232
1233 #[test]
1234 fn test_yaml_keys_with_special_characters() {
1235 let rule = create_enabled_rule();
1236 let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
1238 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1239 let result = rule.check(&ctx).unwrap();
1240
1241 assert!(result.is_empty());
1243 }
1244
1245 #[test]
1246 fn test_yaml_keys_with_numbers() {
1247 let rule = create_enabled_rule();
1248 let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
1249 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1250 let result = rule.check(&ctx).unwrap();
1251
1252 assert!(result.is_empty());
1254 }
1255
1256 #[test]
1257 fn test_yaml_multiline_string_block_literal() {
1258 let rule = create_enabled_rule();
1259 let content =
1260 "---\ndescription: |\n This is a\n multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
1261 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1262 let result = rule.check(&ctx).unwrap();
1263
1264 assert_eq!(result.len(), 1);
1266 assert!(result[0].message.contains("'author' should come before 'title'"));
1267 }
1268
1269 #[test]
1270 fn test_yaml_multiline_string_folded() {
1271 let rule = create_enabled_rule();
1272 let content = "---\ndescription: >\n This is a\n folded string\nauthor: John\n---\n\n# Heading";
1273 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1274 let result = rule.check(&ctx).unwrap();
1275
1276 assert_eq!(result.len(), 1);
1278 }
1279
1280 #[test]
1281 fn test_yaml_fix_preserves_multiline_values() {
1282 let rule = create_enabled_rule();
1283 let content = "---\ntitle: Test\ndescription: |\n Line 1\n Line 2\n---\n\n# Heading";
1284 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1285 let fixed = rule.fix(&ctx).unwrap();
1286
1287 let desc_pos = fixed.find("description").unwrap();
1289 let title_pos = fixed.find("title").unwrap();
1290 assert!(desc_pos < title_pos);
1291 }
1292
1293 #[test]
1294 fn test_yaml_quoted_keys() {
1295 let rule = create_enabled_rule();
1296 let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
1297 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298 let result = rule.check(&ctx).unwrap();
1299
1300 assert!(result.is_empty());
1302 }
1303
1304 #[test]
1305 fn test_yaml_duplicate_keys() {
1306 let rule = create_enabled_rule();
1308 let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1309 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1310 let result = rule.check(&ctx).unwrap();
1311
1312 assert_eq!(result.len(), 1);
1314 }
1315
1316 #[test]
1317 fn test_toml_inline_table() {
1318 let rule = create_enabled_rule();
1319 let content =
1320 "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1321 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1322 let result = rule.check(&ctx).unwrap();
1323
1324 assert!(result.is_empty());
1326 }
1327
1328 #[test]
1329 fn test_toml_array_of_tables() {
1330 let rule = create_enabled_rule();
1331 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1332 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1333 let result = rule.check(&ctx).unwrap();
1334
1335 assert_eq!(result.len(), 1);
1337 assert!(result[0].message.contains("'date' should come before 'title'"));
1339 }
1340
1341 #[test]
1342 fn test_json_nested_objects() {
1343 let rule = create_enabled_rule();
1344 let content = "{\n\"author\": {\n \"name\": \"John\",\n \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1345 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1346 let result = rule.check(&ctx).unwrap();
1347
1348 assert!(result.is_empty());
1350 }
1351
1352 #[test]
1353 fn test_json_arrays() {
1354 let rule = create_enabled_rule();
1355 let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1356 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1357 let result = rule.check(&ctx).unwrap();
1358
1359 assert_eq!(result.len(), 1);
1361 }
1362
1363 #[test]
1364 fn test_fix_preserves_content_after_frontmatter() {
1365 let rule = create_enabled_rule();
1366 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1368 let fixed = rule.fix(&ctx).unwrap();
1369
1370 assert!(fixed.contains("# Heading"));
1372 assert!(fixed.contains("Paragraph 1."));
1373 assert!(fixed.contains("- List item"));
1374 assert!(fixed.contains("- Another item"));
1375 }
1376
1377 #[test]
1378 fn test_fix_yaml_produces_valid_yaml() {
1379 let rule = create_enabled_rule();
1380 let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1381 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1382 let fixed = rule.fix(&ctx).unwrap();
1383
1384 let lines: Vec<&str> = fixed.lines().collect();
1387 let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1388 let fm_content: String = lines[1..fm_end].join("\n");
1389
1390 let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&fm_content);
1392 assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1393 }
1394
1395 #[test]
1396 fn test_fix_toml_produces_valid_toml() {
1397 let rule = create_enabled_rule();
1398 let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400 let fixed = rule.fix(&ctx).unwrap();
1401
1402 let lines: Vec<&str> = fixed.lines().collect();
1404 let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1405 let fm_content: String = lines[1..fm_end].join("\n");
1406
1407 let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1409 assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1410 }
1411
1412 #[test]
1413 fn test_fix_json_produces_valid_json() {
1414 let rule = create_enabled_rule();
1415 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1417 let fixed = rule.fix(&ctx).unwrap();
1418
1419 let json_end = fixed.find("\n\n").unwrap();
1421 let json_content = &fixed[..json_end];
1422
1423 let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1425 assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1426 }
1427
1428 #[test]
1429 fn test_many_keys_performance() {
1430 let rule = create_enabled_rule();
1431 let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1433 keys.reverse(); let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1435
1436 let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1437 let result = rule.check(&ctx).unwrap();
1438
1439 assert_eq!(result.len(), 1);
1441 }
1442
1443 #[test]
1444 fn test_yaml_empty_value() {
1445 let rule = create_enabled_rule();
1446 let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1447 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1448 let result = rule.check(&ctx).unwrap();
1449
1450 assert_eq!(result.len(), 1);
1452 }
1453
1454 #[test]
1455 fn test_yaml_null_value() {
1456 let rule = create_enabled_rule();
1457 let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1458 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1459 let result = rule.check(&ctx).unwrap();
1460
1461 assert_eq!(result.len(), 1);
1462 }
1463
1464 #[test]
1465 fn test_yaml_boolean_values() {
1466 let rule = create_enabled_rule();
1467 let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1468 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1469 let result = rule.check(&ctx).unwrap();
1470
1471 assert_eq!(result.len(), 1);
1473 }
1474
1475 #[test]
1476 fn test_toml_boolean_values() {
1477 let rule = create_enabled_rule();
1478 let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1480 let result = rule.check(&ctx).unwrap();
1481
1482 assert_eq!(result.len(), 1);
1483 }
1484
1485 #[test]
1486 fn test_yaml_list_at_top_level() {
1487 let rule = create_enabled_rule();
1488 let content = "---\ntags:\n - rust\n - markdown\nauthor: John\n---\n\n# Heading";
1489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1490 let result = rule.check(&ctx).unwrap();
1491
1492 assert_eq!(result.len(), 1);
1494 }
1495
1496 #[test]
1497 fn test_three_keys_all_orderings() {
1498 let rule = create_enabled_rule();
1499
1500 let orderings = [
1502 ("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), ];
1509
1510 for (name, content, should_pass) in orderings {
1511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512 let result = rule.check(&ctx).unwrap();
1513 assert_eq!(
1514 result.is_empty(),
1515 should_pass,
1516 "Ordering {name} should {} pass",
1517 if should_pass { "" } else { "not" }
1518 );
1519 }
1520 }
1521
1522 #[test]
1523 fn test_crlf_line_endings() {
1524 let rule = create_enabled_rule();
1525 let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1526 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1527 let result = rule.check(&ctx).unwrap();
1528
1529 assert_eq!(result.len(), 1);
1531 }
1532
1533 #[test]
1534 fn test_json_escaped_quotes_in_keys() {
1535 let rule = create_enabled_rule();
1536 let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1538 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1539 let result = rule.check(&ctx).unwrap();
1540
1541 assert_eq!(result.len(), 1);
1543 }
1544
1545 #[test]
1548 fn test_warning_fix_yaml_sorts_keys() {
1549 let rule = create_enabled_rule();
1550 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1551 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552 let warnings = rule.check(&ctx).unwrap();
1553
1554 assert_eq!(warnings.len(), 1);
1555 assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1556
1557 let fix = warnings[0].fix.as_ref().unwrap();
1558 assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1559
1560 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1562
1563 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1565 let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1566 assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1567 }
1568
1569 #[test]
1570 fn test_warning_fix_preserves_yaml_list_indentation() {
1571 let rule = create_enabled_rule();
1572 let content = "---\nbbb: 123\naaa:\n - hello\n - world\n---\n\n# Heading\n";
1573 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1574 let warnings = rule.check(&ctx).unwrap();
1575
1576 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1577
1578 assert!(
1580 fixed.contains(" - hello"),
1581 "List indentation should be preserved: {fixed}"
1582 );
1583 assert!(
1584 fixed.contains(" - world"),
1585 "List indentation should be preserved: {fixed}"
1586 );
1587 }
1588
1589 #[test]
1590 fn test_warning_fix_preserves_nested_object_indentation() {
1591 let rule = create_enabled_rule();
1592 let content = "---\nzzzz: value\naaaa:\n nested_key: nested_value\n another: 123\n---\n\n# Heading\n";
1593 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1594 let warnings = rule.check(&ctx).unwrap();
1595
1596 assert_eq!(warnings.len(), 1);
1597 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1598
1599 let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1601 let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1602 assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1603
1604 assert!(
1606 fixed.contains(" nested_key: nested_value"),
1607 "Nested object indentation should be preserved: {fixed}"
1608 );
1609 assert!(
1610 fixed.contains(" another: 123"),
1611 "Nested object indentation should be preserved: {fixed}"
1612 );
1613 }
1614
1615 #[test]
1616 fn test_warning_fix_preserves_deeply_nested_structure() {
1617 let rule = create_enabled_rule();
1618 let content = "---\nzzz: top\naaa:\n level1:\n level2:\n - item1\n - item2\n---\n\n# Content\n";
1619 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620 let warnings = rule.check(&ctx).unwrap();
1621
1622 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1623
1624 let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1626 let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1627 assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1628
1629 assert!(fixed.contains(" level1:"), "2-space indent should be preserved");
1631 assert!(fixed.contains(" level2:"), "4-space indent should be preserved");
1632 assert!(fixed.contains(" - item1"), "6-space indent should be preserved");
1633 assert!(fixed.contains(" - item2"), "6-space indent should be preserved");
1634 }
1635
1636 #[test]
1637 fn test_warning_fix_toml_sorts_keys() {
1638 let rule = create_enabled_rule();
1639 let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1641 let warnings = rule.check(&ctx).unwrap();
1642
1643 assert_eq!(warnings.len(), 1);
1644 assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1645
1646 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1647
1648 let author_pos = fixed.find("author").expect("author should exist");
1650 let title_pos = fixed.find("title").expect("title should exist");
1651 assert!(author_pos < title_pos, "author should come before title");
1652 }
1653
1654 #[test]
1655 fn test_warning_fix_json_sorts_keys() {
1656 let rule = create_enabled_rule();
1657 let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1659 let warnings = rule.check(&ctx).unwrap();
1660
1661 assert_eq!(warnings.len(), 1);
1662 assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1663
1664 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1665
1666 let author_pos = fixed.find("author").expect("author should exist");
1668 let title_pos = fixed.find("title").expect("title should exist");
1669 assert!(author_pos < title_pos, "author should come before title");
1670 }
1671
1672 #[test]
1673 fn test_warning_fix_no_fix_when_comments_present() {
1674 let rule = create_enabled_rule();
1675 let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1676 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1677 let warnings = rule.check(&ctx).unwrap();
1678
1679 assert_eq!(warnings.len(), 1);
1680 assert!(
1681 warnings[0].fix.is_none(),
1682 "Warning should NOT have a fix when comments are present"
1683 );
1684 assert!(
1685 warnings[0].message.contains("auto-fix unavailable"),
1686 "Message should indicate auto-fix is unavailable"
1687 );
1688 }
1689
1690 #[test]
1691 fn test_warning_fix_preserves_content_after_frontmatter() {
1692 let rule = create_enabled_rule();
1693 let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1695 let warnings = rule.check(&ctx).unwrap();
1696
1697 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1698
1699 assert!(fixed.contains("# Heading"), "Heading should be preserved");
1701 assert!(
1702 fixed.contains("Paragraph with content."),
1703 "Paragraph should be preserved"
1704 );
1705 assert!(fixed.contains("- List item"), "List item should be preserved");
1706 }
1707
1708 #[test]
1709 fn test_warning_fix_idempotent() {
1710 let rule = create_enabled_rule();
1711 let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1713 let warnings = rule.check(&ctx).unwrap();
1714
1715 let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1716
1717 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1719 let warnings2 = rule.check(&ctx2).unwrap();
1720
1721 assert!(
1722 warnings2.is_empty(),
1723 "After fixing, no more warnings should be produced"
1724 );
1725 }
1726
1727 #[test]
1728 fn test_warning_fix_preserves_multiline_block_literal() {
1729 let rule = create_enabled_rule();
1730 let content = "---\nzzz: simple\naaa: |\n Line 1 of block\n Line 2 of block\n---\n\n# Heading\n";
1731 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1732 let warnings = rule.check(&ctx).unwrap();
1733
1734 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1735
1736 assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1738 assert!(
1739 fixed.contains(" Line 1 of block"),
1740 "Block literal line 1 should be preserved with indent"
1741 );
1742 assert!(
1743 fixed.contains(" Line 2 of block"),
1744 "Block literal line 2 should be preserved with indent"
1745 );
1746 }
1747
1748 #[test]
1749 fn test_warning_fix_preserves_folded_string() {
1750 let rule = create_enabled_rule();
1751 let content = "---\nzzz: simple\naaa: >\n Folded line 1\n Folded line 2\n---\n\n# Content\n";
1752 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1753 let warnings = rule.check(&ctx).unwrap();
1754
1755 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1756
1757 assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1759 assert!(
1760 fixed.contains(" Folded line 1"),
1761 "Folded line 1 should be preserved with indent"
1762 );
1763 assert!(
1764 fixed.contains(" Folded line 2"),
1765 "Folded line 2 should be preserved with indent"
1766 );
1767 }
1768
1769 #[test]
1770 fn test_warning_fix_preserves_4_space_indentation() {
1771 let rule = create_enabled_rule();
1772 let content = "---\nzzz: value\naaa:\n nested: with_4_spaces\n another: value\n---\n\n# Heading\n";
1774 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1775 let warnings = rule.check(&ctx).unwrap();
1776
1777 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1778
1779 assert!(
1781 fixed.contains(" nested: with_4_spaces"),
1782 "4-space indentation should be preserved: {fixed}"
1783 );
1784 assert!(
1785 fixed.contains(" another: value"),
1786 "4-space indentation should be preserved: {fixed}"
1787 );
1788 }
1789
1790 #[test]
1791 fn test_warning_fix_preserves_tab_indentation() {
1792 let rule = create_enabled_rule();
1793 let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1795 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1796 let warnings = rule.check(&ctx).unwrap();
1797
1798 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1799
1800 assert!(
1802 fixed.contains("\tnested: with_tab"),
1803 "Tab indentation should be preserved: {fixed}"
1804 );
1805 assert!(
1806 fixed.contains("\tanother: value"),
1807 "Tab indentation should be preserved: {fixed}"
1808 );
1809 }
1810
1811 #[test]
1812 fn test_warning_fix_preserves_inline_list() {
1813 let rule = create_enabled_rule();
1814 let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1816 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817 let warnings = rule.check(&ctx).unwrap();
1818
1819 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1820
1821 assert!(
1823 fixed.contains("aaa: [one, two, three]"),
1824 "Inline list should be preserved exactly: {fixed}"
1825 );
1826 }
1827
1828 #[test]
1829 fn test_warning_fix_preserves_quoted_strings() {
1830 let rule = create_enabled_rule();
1831 let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1833 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1834 let warnings = rule.check(&ctx).unwrap();
1835
1836 let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1837
1838 assert!(
1840 fixed.contains("aaa: \"value with: colon\""),
1841 "Double-quoted string should be preserved: {fixed}"
1842 );
1843 assert!(
1844 fixed.contains("bbb: 'single quotes'"),
1845 "Single-quoted string should be preserved: {fixed}"
1846 );
1847 }
1848
1849 #[test]
1852 fn test_yaml_custom_key_order_sorted() {
1853 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1855 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1856 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1857 let result = rule.check(&ctx).unwrap();
1858
1859 assert!(result.is_empty());
1861 }
1862
1863 #[test]
1864 fn test_yaml_custom_key_order_unsorted() {
1865 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1867 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1868 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1869 let result = rule.check(&ctx).unwrap();
1870
1871 assert_eq!(result.len(), 1);
1872 assert!(result[0].message.contains("'date' should come before 'author'"));
1874 }
1875
1876 #[test]
1877 fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1878 let rule = create_rule_with_key_order(vec!["title"]);
1880 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1881 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1882 let result = rule.check(&ctx).unwrap();
1883
1884 assert!(result.is_empty());
1887 }
1888
1889 #[test]
1890 fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1891 let rule = create_rule_with_key_order(vec!["title"]);
1893 let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1894 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895 let result = rule.check(&ctx).unwrap();
1896
1897 assert_eq!(result.len(), 1);
1899 assert!(result[0].message.contains("'author' should come before 'zebra'"));
1900 }
1901
1902 #[test]
1903 fn test_yaml_custom_key_order_fix() {
1904 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1905 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1906 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1907 let fixed = rule.fix(&ctx).unwrap();
1908
1909 let title_pos = fixed.find("title:").unwrap();
1911 let date_pos = fixed.find("date:").unwrap();
1912 let author_pos = fixed.find("author:").unwrap();
1913 assert!(
1914 title_pos < date_pos && date_pos < author_pos,
1915 "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1916 );
1917 }
1918
1919 #[test]
1920 fn test_yaml_custom_key_order_fix_with_unlisted() {
1921 let rule = create_rule_with_key_order(vec!["title", "author"]);
1923 let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1924 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1925 let fixed = rule.fix(&ctx).unwrap();
1926
1927 let title_pos = fixed.find("title:").unwrap();
1929 let author_pos = fixed.find("author:").unwrap();
1930 let aardvark_pos = fixed.find("aardvark:").unwrap();
1931 let zebra_pos = fixed.find("zebra:").unwrap();
1932
1933 assert!(
1934 title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1935 "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1936 );
1937 }
1938
1939 #[test]
1940 fn test_toml_custom_key_order_sorted() {
1941 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1942 let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1943 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1944 let result = rule.check(&ctx).unwrap();
1945
1946 assert!(result.is_empty());
1947 }
1948
1949 #[test]
1950 fn test_toml_custom_key_order_unsorted() {
1951 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1952 let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1953 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1954 let result = rule.check(&ctx).unwrap();
1955
1956 assert_eq!(result.len(), 1);
1957 assert!(result[0].message.contains("TOML"));
1958 }
1959
1960 #[test]
1961 fn test_json_custom_key_order_sorted() {
1962 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1963 let content = "{\n \"title\": \"Test\",\n \"date\": \"2024-01-01\",\n \"author\": \"John\"\n}\n\n# Heading";
1964 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1965 let result = rule.check(&ctx).unwrap();
1966
1967 assert!(result.is_empty());
1968 }
1969
1970 #[test]
1971 fn test_json_custom_key_order_unsorted() {
1972 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1973 let content = "{\n \"author\": \"John\",\n \"title\": \"Test\",\n \"date\": \"2024-01-01\"\n}\n\n# Heading";
1974 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1975 let result = rule.check(&ctx).unwrap();
1976
1977 assert_eq!(result.len(), 1);
1978 assert!(result[0].message.contains("JSON"));
1979 }
1980
1981 #[test]
1982 fn test_key_order_case_insensitive_match() {
1983 let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1985 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1986 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1987 let result = rule.check(&ctx).unwrap();
1988
1989 assert!(result.is_empty());
1991 }
1992
1993 #[test]
1994 fn test_key_order_partial_match() {
1995 let rule = create_rule_with_key_order(vec!["title"]);
1997 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1998 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1999 let result = rule.check(&ctx).unwrap();
2000
2001 assert_eq!(result.len(), 1);
2012 assert!(result[0].message.contains("'author' should come before 'date'"));
2013 }
2014
2015 #[test]
2018 fn test_key_order_empty_array_falls_back_to_alphabetical() {
2019 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2021 enabled: true,
2022 key_order: Some(vec![]),
2023 ..Default::default()
2024 });
2025 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2026 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2027 let result = rule.check(&ctx).unwrap();
2028
2029 assert_eq!(result.len(), 1);
2032 assert!(result[0].message.contains("'author' should come before 'title'"));
2033 }
2034
2035 #[test]
2036 fn test_key_order_single_key() {
2037 let rule = create_rule_with_key_order(vec!["title"]);
2039 let content = "---\ntitle: Test\n---\n\n# Heading";
2040 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2041 let result = rule.check(&ctx).unwrap();
2042
2043 assert!(result.is_empty());
2044 }
2045
2046 #[test]
2047 fn test_key_order_all_keys_specified() {
2048 let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
2050 let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
2051 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2052 let result = rule.check(&ctx).unwrap();
2053
2054 assert!(result.is_empty());
2055 }
2056
2057 #[test]
2058 fn test_key_order_no_keys_match() {
2059 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
2061 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
2062 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2063 let result = rule.check(&ctx).unwrap();
2064
2065 assert!(result.is_empty());
2068 }
2069
2070 #[test]
2071 fn test_key_order_no_keys_match_unsorted() {
2072 let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
2074 let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
2075 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2076 let result = rule.check(&ctx).unwrap();
2077
2078 assert_eq!(result.len(), 1);
2081 }
2082
2083 #[test]
2084 fn test_key_order_duplicate_keys_in_config() {
2085 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2087 enabled: true,
2088 key_order: Some(vec![
2089 "title".to_string(),
2090 "author".to_string(),
2091 "title".to_string(), ]),
2093 ..Default::default()
2094 });
2095 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2096 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2097 let result = rule.check(&ctx).unwrap();
2098
2099 assert!(result.is_empty());
2101 }
2102
2103 #[test]
2104 fn test_key_order_with_comments_still_skips_fix() {
2105 let rule = create_rule_with_key_order(vec!["title", "author"]);
2107 let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
2108 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2109 let result = rule.check(&ctx).unwrap();
2110
2111 assert_eq!(result.len(), 1);
2113 assert!(result[0].message.contains("auto-fix unavailable"));
2114 assert!(result[0].fix.is_none());
2115 }
2116
2117 #[test]
2118 fn test_toml_custom_key_order_fix() {
2119 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
2120 let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
2121 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2122 let fixed = rule.fix(&ctx).unwrap();
2123
2124 let title_pos = fixed.find("title").unwrap();
2126 let date_pos = fixed.find("date").unwrap();
2127 let author_pos = fixed.find("author").unwrap();
2128 assert!(
2129 title_pos < date_pos && date_pos < author_pos,
2130 "Fixed TOML should have keys in custom order. Got:\n{fixed}"
2131 );
2132 }
2133
2134 #[test]
2135 fn test_json_custom_key_order_fix() {
2136 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
2137 let content = "{\n \"author\": \"John\",\n \"date\": \"2024-01-01\",\n \"title\": \"Test\"\n}\n\n# Heading";
2138 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2139 let fixed = rule.fix(&ctx).unwrap();
2140
2141 let title_pos = fixed.find("\"title\"").unwrap();
2143 let date_pos = fixed.find("\"date\"").unwrap();
2144 let author_pos = fixed.find("\"author\"").unwrap();
2145 assert!(
2146 title_pos < date_pos && date_pos < author_pos,
2147 "Fixed JSON should have keys in custom order. Got:\n{fixed}"
2148 );
2149 }
2150
2151 #[test]
2152 fn test_key_order_unicode_keys() {
2153 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2155 enabled: true,
2156 key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
2157 ..Default::default()
2158 });
2159 let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
2160 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2161 let result = rule.check(&ctx).unwrap();
2162
2163 assert!(result.is_empty());
2165 }
2166
2167 #[test]
2168 fn test_key_order_mixed_specified_and_unlisted_boundary() {
2169 let rule = create_rule_with_key_order(vec!["z_last_specified"]);
2171 let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
2172 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2173 let result = rule.check(&ctx).unwrap();
2174
2175 assert!(result.is_empty());
2178 }
2179
2180 #[test]
2181 fn test_key_order_fix_preserves_values() {
2182 let rule = create_rule_with_key_order(vec!["title", "tags"]);
2184 let content = "---\ntags:\n - rust\n - markdown\ntitle: Test\n---\n\n# Heading";
2185 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2186 let fixed = rule.fix(&ctx).unwrap();
2187
2188 let title_pos = fixed.find("title:").unwrap();
2190 let tags_pos = fixed.find("tags:").unwrap();
2191 assert!(title_pos < tags_pos, "title should come before tags");
2192
2193 assert!(fixed.contains("- rust"), "List items should be preserved");
2195 assert!(fixed.contains("- markdown"), "List items should be preserved");
2196 }
2197
2198 #[test]
2199 fn test_key_order_idempotent_fix() {
2200 let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
2202 let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
2203 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2204
2205 let fixed_once = rule.fix(&ctx).unwrap();
2206 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2207 let fixed_twice = rule.fix(&ctx2).unwrap();
2208
2209 assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2210 }
2211
2212 #[test]
2213 fn test_key_order_respects_later_position_over_alphabetical() {
2214 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2216 let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
2217 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2218 let result = rule.check(&ctx).unwrap();
2219
2220 assert!(result.is_empty());
2222 }
2223
2224 #[test]
2227 fn test_json_braces_in_string_values_extracts_all_keys() {
2228 let rule = create_enabled_rule();
2232 let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
2233 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2234 let result = rule.check(&ctx).unwrap();
2235
2236 assert!(
2238 result.is_empty(),
2239 "All keys should be extracted and recognized as sorted. Got: {result:?}"
2240 );
2241 }
2242
2243 #[test]
2244 fn test_json_braces_in_string_key_after_brace_value_detected() {
2245 let rule = create_enabled_rule();
2247 let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2250 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2251 let result = rule.check(&ctx).unwrap();
2252
2253 assert_eq!(
2256 result.len(),
2257 1,
2258 "Should detect unsorted keys after brace-containing string value"
2259 );
2260 assert!(
2261 result[0].message.contains("'author' should come before 'description'"),
2262 "Should report author before description. Got: {}",
2263 result[0].message
2264 );
2265 }
2266
2267 #[test]
2268 fn test_json_brackets_in_string_values() {
2269 let rule = create_enabled_rule();
2271 let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2272 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2273 let result = rule.check(&ctx).unwrap();
2274
2275 assert_eq!(
2277 result.len(),
2278 1,
2279 "Should detect unsorted keys despite brackets in string values"
2280 );
2281 assert!(
2282 result[0].message.contains("'author' should come before 'description'"),
2283 "Got: {}",
2284 result[0].message
2285 );
2286 }
2287
2288 #[test]
2289 fn test_json_escaped_quotes_in_values() {
2290 let rule = create_enabled_rule();
2292 let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2293 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2294 let result = rule.check(&ctx).unwrap();
2295
2296 assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
2298 assert!(
2299 result[0].message.contains("'author' should come before 'title'"),
2300 "Got: {}",
2301 result[0].message
2302 );
2303 }
2304
2305 #[test]
2306 fn test_json_multiple_braces_in_string() {
2307 let rule = create_enabled_rule();
2309 let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2310 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2311 let result = rule.check(&ctx).unwrap();
2312
2313 assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
2315 assert!(
2316 result[0].message.contains("'author' should come before 'pattern'"),
2317 "Got: {}",
2318 result[0].message
2319 );
2320 }
2321
2322 #[test]
2323 fn test_key_order_detects_wrong_custom_order() {
2324 let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2326 let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
2327 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2328 let result = rule.check(&ctx).unwrap();
2329
2330 assert_eq!(result.len(), 1);
2331 assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
2332 }
2333
2334 #[test]
2337 fn test_required_keys_yaml_missing_key_warns_without_fix() {
2338 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2339 let content = "---\ntitle: Test\n---\n\n# Heading";
2340 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2341 let result = rule.check(&ctx).unwrap();
2342
2343 assert_eq!(result.len(), 1);
2344 assert!(result[0].message.contains("missing required key 'date'"));
2345 assert!(result[0].message.contains("YAML"));
2346 assert!(result[0].fix.is_none(), "missing keys must not be auto-fixable");
2347 assert_eq!(result[0].line, 1);
2349 assert_eq!(result[0].column, 1);
2350 assert_eq!(result[0].end_column, 4);
2351 }
2352
2353 #[test]
2354 fn test_required_keys_all_present_no_warning() {
2355 let rule = create_rule_with_required_keys(vec!["author", "title"]);
2356 let content = "---\nauthor: John\ntitle: Test\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!(result.is_empty());
2361 }
2362
2363 #[test]
2364 fn test_required_keys_one_warning_per_missing_key() {
2365 let rule = create_rule_with_required_keys(vec!["title", "date", "author"]);
2366 let content = "---\ntags: [a, b]\n---\n\n# Heading";
2367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2368 let result = rule.check(&ctx).unwrap();
2369
2370 assert_eq!(result.len(), 3);
2371 let messages: Vec<&str> = result.iter().map(|w| w.message.as_str()).collect();
2372 assert!(messages.iter().any(|m| m.contains("'title'")));
2373 assert!(messages.iter().any(|m| m.contains("'date'")));
2374 assert!(messages.iter().any(|m| m.contains("'author'")));
2375 }
2376
2377 #[test]
2378 fn test_required_keys_case_insensitive_match() {
2379 let rule = create_rule_with_required_keys(vec!["Title"]);
2381 let content = "---\ntitle: Test\n---\n\n# Heading";
2382 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2383 let result = rule.check(&ctx).unwrap();
2384
2385 assert!(result.is_empty());
2386 }
2387
2388 #[test]
2389 fn test_required_keys_missing_and_unsorted_both_reported() {
2390 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2391 enabled: true,
2392 required_keys: vec!["date".to_string()],
2393 ..Default::default()
2394 });
2395 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2397 let result = rule.check(&ctx).unwrap();
2398
2399 assert_eq!(result.len(), 2);
2400 assert!(result[0].message.contains("missing required key 'date'"));
2401 assert!(result[1].message.contains("'author' should come before 'title'"));
2402 }
2403
2404 #[test]
2405 fn test_required_keys_toml_missing_key() {
2406 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2407 let content = "+++\ntitle = \"Test\"\n+++\n\n# Heading";
2408 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2409 let result = rule.check(&ctx).unwrap();
2410
2411 assert_eq!(result.len(), 1);
2412 assert!(
2413 result[0]
2414 .message
2415 .contains("TOML frontmatter is missing required key 'date'")
2416 );
2417 assert!(result[0].fix.is_none());
2418 }
2419
2420 #[test]
2421 fn test_required_keys_json_missing_key() {
2422 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2423 let content = "{\n\"title\": \"Test\"\n}\n\n# Heading";
2424 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2425 let result = rule.check(&ctx).unwrap();
2426
2427 assert_eq!(result.len(), 1);
2428 assert!(
2429 result[0]
2430 .message
2431 .contains("JSON frontmatter is missing required key 'date'")
2432 );
2433 assert_eq!(result[0].end_column, 2);
2435 }
2436
2437 #[test]
2438 fn test_required_keys_no_frontmatter_no_warning() {
2439 let rule = create_rule_with_required_keys(vec!["title"]);
2442 let content = "# Heading\n\nContent.";
2443 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2444 let result = rule.check(&ctx).unwrap();
2445
2446 assert!(result.is_empty());
2447 }
2448
2449 #[test]
2450 fn test_required_keys_empty_frontmatter_warns() {
2451 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2453 let content = "---\n---\n\n# Heading";
2454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2455 let result = rule.check(&ctx).unwrap();
2456
2457 assert_eq!(result.len(), 2);
2458 assert!(result.iter().all(|w| w.message.contains("missing required key")));
2459 }
2460
2461 #[test]
2462 fn test_required_keys_nested_key_does_not_satisfy() {
2463 let rule = create_rule_with_required_keys(vec!["title"]);
2465 let content = "---\nmeta:\n title: Nested\n---\n\n# Heading";
2466 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2467 let result = rule.check(&ctx).unwrap();
2468
2469 assert_eq!(result.len(), 1);
2470 assert!(result[0].message.contains("missing required key 'title'"));
2471 }
2472
2473 #[test]
2474 fn test_required_keys_quoted_yaml_key_satisfies() {
2475 let rule = create_rule_with_required_keys(vec!["title"]);
2477 let content = "---\n\"title\": Test\n---\n\n# Heading";
2478 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2479 let result = rule.check(&ctx).unwrap();
2480
2481 assert!(result.is_empty());
2482 }
2483
2484 #[test]
2485 fn test_required_keys_fix_does_not_insert_keys() {
2486 let rule = create_rule_with_required_keys(vec!["date"]);
2489 let content = "---\nauthor: John\ntitle: Test\n---\n\n# Heading";
2490 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2491 let fixed = rule.fix(&ctx).unwrap();
2492
2493 assert_eq!(fixed, content);
2494 }
2495
2496 #[test]
2497 fn test_required_keys_with_key_order_subset() {
2498 let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
2501 enabled: true,
2502 key_order: Some(vec![
2503 "title".to_string(),
2504 "date".to_string(),
2505 "author".to_string(),
2506 "tags".to_string(),
2507 ]),
2508 required_keys: vec!["title".to_string(), "date".to_string()],
2509 });
2510
2511 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2513 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2514 let result = rule.check(&ctx).unwrap();
2515 assert_eq!(result.len(), 1);
2516 assert!(result[0].message.contains("missing required key 'date'"));
2517
2518 let content = "---\ntitle: Test\ndate: 2024-01-01\ntags: [a]\n---\n\n# Heading";
2520 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2521 let result = rule.check(&ctx).unwrap();
2522 assert!(result.is_empty());
2523 }
2524
2525 #[test]
2526 fn test_required_keys_unsorted_fix_still_applies_without_inserting() {
2527 let rule = create_rule_with_required_keys(vec!["date"]);
2530 let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
2531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2532 let fixed = rule.fix(&ctx).unwrap();
2533
2534 let author_pos = fixed.find("author:").unwrap();
2535 let title_pos = fixed.find("title:").unwrap();
2536 assert!(author_pos < title_pos, "sort fix must still apply");
2537 assert!(!fixed.contains("date"), "fix must not insert the missing key");
2538
2539 let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
2540 let result = rule.check(&ctx2).unwrap();
2541 assert_eq!(result.len(), 1);
2542 assert!(result[0].message.contains("missing required key 'date'"));
2543 }
2544
2545 #[test]
2546 fn test_required_keys_warning_spans_the_frontmatter_block() {
2547 let rule = create_rule_with_required_keys(vec!["date"]);
2551 let content = "---\ntitle: Test\n---\n\n# Heading";
2552 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2553 let result = rule.check(&ctx).unwrap();
2554
2555 assert_eq!(result.len(), 1);
2556 assert_eq!(result[0].line, 1);
2557 assert_eq!(result[0].column, 1);
2558 assert_eq!(result[0].end_line, 3, "span must reach the closing fence line");
2559 assert_eq!(result[0].end_column, 4);
2560 }
2561
2562 #[test]
2563 fn test_required_keys_suppressed_by_inline_disable_in_frontmatter() {
2564 let rule = create_rule_with_required_keys(vec!["date"]);
2569 let content = "---\n# <!-- rumdl-disable MD072 -->\ntitle: Test\n---\n\n# Heading\n";
2570 let warnings = crate::lint(
2571 content,
2572 &[Box::new(rule) as Box<dyn Rule>],
2573 false,
2574 crate::config::MarkdownFlavor::Standard,
2575 None,
2576 None,
2577 )
2578 .unwrap();
2579
2580 assert!(
2581 warnings.is_empty(),
2582 "inline disable inside the frontmatter must suppress missing-key warnings, got: {warnings:?}"
2583 );
2584 }
2585
2586 #[test]
2587 fn test_required_keys_reported_through_lint_without_disable() {
2588 let rule = create_rule_with_required_keys(vec!["date"]);
2591 let content = "---\ntitle: Test\n---\n\n# Heading\n";
2592 let warnings = crate::lint(
2593 content,
2594 &[Box::new(rule) as Box<dyn Rule>],
2595 false,
2596 crate::config::MarkdownFlavor::Standard,
2597 None,
2598 None,
2599 )
2600 .unwrap();
2601
2602 assert_eq!(warnings.len(), 1);
2603 assert!(warnings[0].message.contains("missing required key 'date'"));
2604 }
2605
2606 #[test]
2607 fn test_required_keys_quoted_toml_key_satisfies() {
2608 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2610 let content = "+++\n\"date\" = \"2024-01-01\"\n'title' = \"Test\"\n+++\n\n# Heading";
2611 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2612 let result = rule.check(&ctx).unwrap();
2613
2614 assert!(
2615 result.is_empty(),
2616 "quoted TOML keys must satisfy required-keys, got: {result:?}"
2617 );
2618 }
2619
2620 #[test]
2621 fn test_toml_quoted_keys_sort_by_content() {
2622 let rule = create_enabled_rule();
2626 let content = "+++\n\"zebra\" = 1\napple = 2\n+++\n\n# Heading";
2627 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2628 let result = rule.check(&ctx).unwrap();
2629
2630 assert_eq!(result.len(), 1, "quoted TOML key out of order must be flagged");
2631 assert!(result[0].message.contains("'apple' should come before 'zebra'"));
2632 }
2633
2634 #[test]
2635 fn test_toml_quoted_key_warning_span_covers_quotes() {
2636 let rule = create_enabled_rule();
2639 let content = "+++\nbanana = 1\n\"apple\" = 2\n+++\n";
2640 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2641 let result = rule.check(&ctx).unwrap();
2642
2643 assert_eq!(result.len(), 1);
2644 let w = &result[0];
2645 assert_eq!(w.line, 3);
2646 assert_eq!(w.column, 1);
2647 assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
2649 }
2650
2651 #[test]
2652 fn test_required_keys_json_multiple_keys_on_one_line() {
2653 let rule = create_rule_with_required_keys(vec!["title", "date"]);
2657 let content = "{\n\"title\": \"Test\", \"date\": \"2024-01-01\"\n}\n\n# Heading";
2658 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2659 let result = rule.check(&ctx).unwrap();
2660
2661 assert!(
2662 result.is_empty(),
2663 "all keys on one JSON line must satisfy required-keys, got: {result:?}"
2664 );
2665 }
2666
2667 #[test]
2668 fn test_required_keys_json_multiple_keys_on_one_line_missing_still_reported() {
2669 let rule = create_rule_with_required_keys(vec!["title", "date", "author"]);
2671 let content = "{\n\"title\": \"Test\", \"date\": \"2024-01-01\"\n}\n\n# Heading";
2672 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2673 let result = rule.check(&ctx).unwrap();
2674
2675 assert_eq!(result.len(), 1);
2676 assert!(result[0].message.contains("missing required key 'author'"));
2677 }
2678
2679 #[test]
2680 fn test_required_keys_json_invalid_falls_back_to_line_based_keys() {
2681 let rule = create_rule_with_required_keys(vec!["title"]);
2684 let content = "{\n\"title\": unquoted-invalid\n}\n\n# Heading";
2685 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2686 let result = rule.check(&ctx).unwrap();
2687
2688 assert!(
2689 result.is_empty(),
2690 "invalid JSON must fall back to line-based key extraction, got: {result:?}"
2691 );
2692 }
2693
2694 #[test]
2695 fn test_required_keys_toml_table_header_satisfies() {
2696 let rule = create_rule_with_required_keys(vec!["title", "taxonomies"]);
2700 let content = "+++\ntitle = \"Test\"\n\n[taxonomies]\ntags = [\"a\"]\n+++\n\n# Heading";
2701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2702 let result = rule.check(&ctx).unwrap();
2703
2704 assert!(
2705 result.is_empty(),
2706 "a TOML table header must satisfy required-keys, got: {result:?}"
2707 );
2708 }
2709
2710 #[test]
2711 fn test_required_keys_toml_array_of_tables_satisfies() {
2712 let rule = create_rule_with_required_keys(vec!["authors"]);
2714 let content = "+++\ntitle = \"Test\"\n\n[[authors]]\nname = \"John\"\n+++\n\n# Heading";
2715 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2716 let result = rule.check(&ctx).unwrap();
2717
2718 assert!(
2719 result.is_empty(),
2720 "a TOML array-of-tables header must satisfy required-keys, got: {result:?}"
2721 );
2722 }
2723
2724 #[test]
2725 fn test_required_keys_toml_dotted_table_header_satisfies_root() {
2726 let rule = create_rule_with_required_keys(vec!["params"]);
2728 let content = "+++\ntitle = \"Test\"\n\n[params.seo]\nnoindex = true\n+++\n\n# Heading";
2729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2730 let result = rule.check(&ctx).unwrap();
2731
2732 assert!(
2733 result.is_empty(),
2734 "a dotted TOML table header must satisfy its root key, got: {result:?}"
2735 );
2736 }
2737
2738 #[test]
2739 fn test_required_keys_toml_missing_despite_other_tables() {
2740 let rule = create_rule_with_required_keys(vec!["date"]);
2742 let content = "+++\ntitle = \"Test\"\n\n[taxonomies]\ntags = [\"a\"]\n+++\n\n# Heading";
2743 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2744 let result = rule.check(&ctx).unwrap();
2745
2746 assert_eq!(result.len(), 1);
2747 assert!(result[0].message.contains("missing required key 'date'"));
2748 }
2749
2750 #[test]
2751 fn test_required_keys_toml_dotted_assignment_satisfies_root() {
2752 let rule = create_rule_with_required_keys(vec!["params"]);
2754 let content = "+++\nparams.seo = true\ntitle = \"Test\"\n+++\n\n# Heading";
2755 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2756 let result = rule.check(&ctx).unwrap();
2757
2758 assert!(
2759 result.is_empty(),
2760 "a dotted TOML assignment must satisfy its root key, got: {result:?}"
2761 );
2762 }
2763
2764 #[test]
2765 fn test_required_keys_toml_quoted_dotted_key_is_atomic() {
2766 let rule = create_rule_with_required_keys(vec!["a.b"]);
2768 let content = "+++\n\"a.b\" = 1\n+++\n\n# Heading";
2769 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2770 let result = rule.check(&ctx).unwrap();
2771 assert!(
2772 result.is_empty(),
2773 "quoted dotted key must match literally, got: {result:?}"
2774 );
2775
2776 let rule = create_rule_with_required_keys(vec!["a"]);
2777 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2778 let result = rule.check(&ctx).unwrap();
2779 assert_eq!(result.len(), 1, "quoted dotted key must NOT satisfy its first segment");
2780 assert!(result[0].message.contains("missing required key 'a'"));
2781 }
2782
2783 #[test]
2784 fn test_required_keys_toml_table_header_with_inline_comment() {
2785 let rule = create_rule_with_required_keys(vec!["taxonomies"]);
2787 let content = "+++\ntitle = \"Test\"\n\n[taxonomies] # used by Hugo\ntags = [\"a\"]\n+++\n\n# Heading";
2788 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2789 let result = rule.check(&ctx).unwrap();
2790
2791 assert!(
2792 result.is_empty(),
2793 "a table header with an inline comment must satisfy required-keys, got: {result:?}"
2794 );
2795 }
2796
2797 #[test]
2798 fn test_required_keys_toml_assignment_inside_table_does_not_satisfy() {
2799 let rule = create_rule_with_required_keys(vec!["date"]);
2801 let content = "+++\ntitle = \"Test\"\n\n[params]\ndate = \"2024-01-01\"\n+++\n\n# Heading";
2802 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2803 let result = rule.check(&ctx).unwrap();
2804
2805 assert_eq!(result.len(), 1);
2806 assert!(result[0].message.contains("missing required key 'date'"));
2807 }
2808
2809 #[test]
2810 fn test_required_keys_yaml_quoted_key_with_colon_satisfies() {
2811 let rule = create_rule_with_required_keys(vec!["og:title"]);
2814 let content = "---\n\"og:title\": My post\n---\n\n# Heading";
2815 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2816 let result = rule.check(&ctx).unwrap();
2817
2818 assert!(
2819 result.is_empty(),
2820 "a quoted YAML key containing a colon must satisfy required-keys, got: {result:?}"
2821 );
2822 }
2823
2824 #[test]
2825 fn test_yaml_quoted_key_with_colon_sorts_by_full_content() {
2826 let rule = create_enabled_rule();
2828 let content = "---\n\"og:title\": My post\nalpha: 1\n---\n\n# Heading";
2829 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2830 let result = rule.check(&ctx).unwrap();
2831
2832 assert_eq!(result.len(), 1);
2833 assert!(
2834 result[0].message.contains("'alpha' should come before 'og:title'"),
2835 "sorting must use the full quoted key, got: {}",
2836 result[0].message
2837 );
2838 }
2839
2840 #[test]
2841 fn test_required_keys_toml_quoted_key_with_equals_satisfies() {
2842 let rule = create_rule_with_required_keys(vec!["a=b"]);
2845 let content = "+++\n\"a=b\" = 1\n+++\n\n# Heading";
2846 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2847 let result = rule.check(&ctx).unwrap();
2848
2849 assert!(
2850 result.is_empty(),
2851 "a quoted TOML key containing '=' must satisfy required-keys, got: {result:?}"
2852 );
2853 }
2854}