1use crate::config::Config;
2use crate::lint_context::LintContext;
3use crate::rule::{FixCapability, LintWarning, Rule};
4use std::collections::hash_map::DefaultHasher;
5use std::collections::{HashMap, HashSet};
6use std::hash::{Hash, Hasher};
7
8const MAX_ITERATIONS: usize = 100;
10
11#[derive(Debug, Clone)]
16pub struct FixResult {
17 pub rules_fixed: usize,
19 pub iterations: usize,
21 pub context_creations: usize,
23 pub fixed_rule_names: HashSet<String>,
25 pub converged: bool,
27 pub conflicting_rules: Vec<String>,
31 pub conflict_cycle: Vec<String>,
35}
36
37fn hash_content(content: &str) -> u64 {
39 let mut hasher = DefaultHasher::new();
40 content.hash(&mut hasher);
41 hasher.finish()
42}
43
44pub struct FixCoordinator {
46 dependencies: HashMap<&'static str, Vec<&'static str>>,
48}
49
50impl Default for FixCoordinator {
51 fn default() -> Self {
52 Self::new()
53 }
54}
55
56impl FixCoordinator {
57 pub fn new() -> Self {
58 let mut dependencies = HashMap::new();
59
60 dependencies.insert("MD064", vec!["MD010"]);
67
68 dependencies.insert("MD010", vec!["MD007", "MD005"]);
72
73 dependencies.insert("MD013", vec!["MD009", "MD012"]);
78
79 dependencies.insert("MD004", vec!["MD007"]);
82
83 dependencies.insert("MD022", vec!["MD012"]);
86 dependencies.insert("MD023", vec!["MD012"]);
87
88 dependencies.insert("MD070", vec!["MD040", "MD031"]);
92
93 dependencies.insert("MD005", vec!["MD032"]);
98 dependencies.insert("MD077", vec!["MD032"]);
99
100 Self { dependencies }
101 }
102
103 pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
105 let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
107
108 let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
110 for (prereq, dependents) in &self.dependencies {
111 for dependent in dependents {
112 reverse_deps.entry(dependent).or_default().insert(prereq);
113 }
114 }
115
116 let mut sorted = Vec::new();
118 let mut visited: HashSet<&str> = HashSet::new();
119 let mut visiting: HashSet<&str> = HashSet::new();
120
121 fn visit<'a, 'b>(
122 rule_name: &'b str,
123 rule_map: &HashMap<&str, &'a dyn Rule>,
124 reverse_deps: &HashMap<&'b str, HashSet<&'b str>>,
125 visited: &mut HashSet<&'b str>,
126 visiting: &mut HashSet<&'b str>,
127 sorted: &mut Vec<&'a dyn Rule>,
128 ) where
129 'a: 'b,
130 {
131 if visited.contains(rule_name) {
132 return;
133 }
134
135 if visiting.contains(rule_name) {
136 return;
138 }
139
140 visiting.insert(rule_name);
141
142 if let Some(deps) = reverse_deps.get(rule_name) {
144 for dep in deps {
145 if rule_map.contains_key(dep) {
146 visit(dep, rule_map, reverse_deps, visited, visiting, sorted);
147 }
148 }
149 }
150
151 visiting.remove(rule_name);
152 visited.insert(rule_name);
153
154 if let Some(&rule) = rule_map.get(rule_name) {
156 sorted.push(rule);
157 }
158 }
159
160 for rule in rules {
162 visit(
163 rule.name(),
164 &rule_map,
165 &reverse_deps,
166 &mut visited,
167 &mut visiting,
168 &mut sorted,
169 );
170 }
171
172 for rule in rules {
174 if !sorted.iter().any(|r| r.name() == rule.name()) {
175 sorted.push(rule.as_ref());
176 }
177 }
178
179 sorted
180 }
181
182 pub fn apply_fixes_iterative(
190 &self,
191 rules: &[Box<dyn Rule>],
192 all_warnings: &[LintWarning], content: &mut String,
194 config: &Config,
195 max_iterations: usize,
196 file_path: Option<&std::path::Path>,
197 ) -> Result<FixResult, String> {
198 self.apply_fixes_iterative_with_paths(
199 rules,
200 all_warnings,
201 content,
202 config,
203 max_iterations,
204 crate::DocumentPaths::same(file_path),
205 )
206 }
207
208 pub fn apply_fixes_iterative_with_paths(
214 &self,
215 rules: &[Box<dyn Rule>],
216 _all_warnings: &[LintWarning],
217 content: &mut String,
218 config: &Config,
219 max_iterations: usize,
220 paths: crate::DocumentPaths<'_>,
221 ) -> Result<FixResult, String> {
222 let max_iterations = max_iterations.min(MAX_ITERATIONS);
224
225 let ordered_rules = self.get_optimal_order(rules);
227
228 let mut total_fixed = 0;
229 let mut total_ctx_creations = 0;
230 let mut iterations = 0;
231
232 let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
235
236 let mut fixed_rule_names: HashSet<&str> = HashSet::new();
238
239 let unfixable_rules: HashSet<String> = config.global.unfixable.iter().cloned().collect();
242 let fixable_rules: HashSet<String> = config.global.fixable.iter().cloned().collect();
243 let has_fixable_allowlist = !fixable_rules.is_empty();
244
245 let ignored_for_file: HashSet<String> = paths
251 .config_path
252 .map(|p| config.get_ignored_rules_for_file(p))
253 .unwrap_or_default();
254
255 while iterations < max_iterations {
257 iterations += 1;
258
259 let flavor = paths
262 .config_path
263 .map_or_else(|| config.markdown_flavor(), |path| config.get_flavor_for_file(path));
264 let ctx = LintContext::new(content, flavor, paths.source_file.map(std::path::Path::to_path_buf));
265 total_ctx_creations += 1;
266
267 let recreated_rules: HashMap<String, Box<dyn Rule>> = {
273 let inline_overrides = ctx.inline_config().get_all_rule_configs();
274 if inline_overrides.is_empty() {
275 HashMap::new()
276 } else {
277 let merged = config.merge_with_inline_config(ctx.inline_config());
278 inline_overrides
279 .keys()
280 .filter_map(|name| {
281 crate::rules::create_rule_by_name(name, &merged).map(|rule| (name.clone(), rule))
282 })
283 .collect()
284 }
285 };
286
287 let mut any_fix_applied = false;
288 let mut this_iter_rule: &str = "";
290
291 for rule in &ordered_rules {
293 if unfixable_rules.contains(rule.name()) {
295 continue;
296 }
297 if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
298 continue;
299 }
300 if ignored_for_file.contains(rule.name()) {
302 continue;
303 }
304
305 let effective_rule: &dyn Rule = recreated_rules.get(rule.name()).map_or(*rule, |r| r.as_ref());
309
310 if effective_rule.should_skip(&ctx) {
312 continue;
313 }
314
315 let Ok(warnings) = effective_rule.check(&ctx) else {
317 continue;
318 };
319
320 if warnings.is_empty() {
321 continue;
322 }
323
324 let inline_config = ctx.inline_config();
326 let filtered_warnings =
327 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
328
329 if filtered_warnings.is_empty() {
330 continue;
331 }
332
333 let has_inline_fix = filtered_warnings.iter().any(|w| w.fix.is_some());
344 let rule_advertises_fix = effective_rule.fix_capability() != FixCapability::Unfixable;
345 if !has_inline_fix && !rule_advertises_fix {
346 continue;
347 }
348
349 match effective_rule.fix(&ctx) {
351 Ok(fixed_content) => {
352 if fixed_content != *content {
353 *content = fixed_content;
354 total_fixed += 1;
355 any_fix_applied = true;
356 this_iter_rule = rule.name();
357 fixed_rule_names.insert(rule.name());
358
359 break;
363 }
364 }
365 Err(_) => {
366 continue;
368 }
369 }
370 }
371
372 let current_hash = hash_content(content);
373
374 if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
376 if cycle_start == history.len() - 1 {
377 return Ok(FixResult {
379 rules_fixed: total_fixed,
380 iterations,
381 context_creations: total_ctx_creations,
382 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
383 converged: true,
384 conflicting_rules: Vec::new(),
385 conflict_cycle: Vec::new(),
386 });
387 } else {
388 let conflict_cycle: Vec<String> = history[cycle_start + 1..]
391 .iter()
392 .map(|(_, r)| r.to_string())
393 .chain(std::iter::once(this_iter_rule.to_string()))
394 .filter(|r| !r.is_empty())
395 .collect();
396 let conflicting_rules: Vec<String> = history[cycle_start + 1..]
397 .iter()
398 .map(|(_, r)| *r)
399 .chain(std::iter::once(this_iter_rule))
400 .filter(|r| !r.is_empty())
401 .collect::<HashSet<&str>>()
402 .into_iter()
403 .map(std::string::ToString::to_string)
404 .collect();
405 return Ok(FixResult {
406 rules_fixed: total_fixed,
407 iterations,
408 context_creations: total_ctx_creations,
409 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
410 converged: false,
411 conflicting_rules,
412 conflict_cycle,
413 });
414 }
415 }
416
417 history.push((current_hash, this_iter_rule));
419
420 if !any_fix_applied {
422 return Ok(FixResult {
423 rules_fixed: total_fixed,
424 iterations,
425 context_creations: total_ctx_creations,
426 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
427 converged: true,
428 conflicting_rules: Vec::new(),
429 conflict_cycle: Vec::new(),
430 });
431 }
432 }
433
434 Ok(FixResult {
436 rules_fixed: total_fixed,
437 iterations,
438 context_creations: total_ctx_creations,
439 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
440 converged: false,
441 conflicting_rules: Vec::new(),
442 conflict_cycle: Vec::new(),
443 })
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450 use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
451 use std::sync::atomic::{AtomicUsize, Ordering};
452
453 #[derive(Clone)]
455 struct ConditionalFixRule {
456 name: &'static str,
457 check_fn: fn(&str) -> bool,
459 fix_fn: fn(&str) -> String,
461 }
462
463 impl Rule for ConditionalFixRule {
464 fn name(&self) -> &'static str {
465 self.name
466 }
467
468 fn check(&self, ctx: &LintContext) -> LintResult {
469 if (self.check_fn)(ctx.content) {
470 Ok(vec![LintWarning {
471 line: 1,
472 column: 1,
473 end_line: 1,
474 end_column: 1,
475 message: format!("{} issue found", self.name),
476 rule_name: Some(self.name.to_string()),
477 severity: Severity::Error,
478 fix: Some(Fix::new(0..0, String::new())),
479 }])
480 } else {
481 Ok(vec![])
482 }
483 }
484
485 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
486 Ok((self.fix_fn)(ctx.content))
487 }
488
489 fn description(&self) -> &'static str {
490 "Conditional fix rule for testing"
491 }
492
493 fn category(&self) -> RuleCategory {
494 RuleCategory::Whitespace
495 }
496
497 fn as_any(&self) -> &dyn std::any::Any {
498 self
499 }
500 }
501
502 #[derive(Clone)]
504 struct MockRule {
505 name: &'static str,
506 warnings: Vec<LintWarning>,
507 fix_content: String,
508 }
509
510 impl Rule for MockRule {
511 fn name(&self) -> &'static str {
512 self.name
513 }
514
515 fn check(&self, _ctx: &LintContext) -> LintResult {
516 Ok(self.warnings.clone())
517 }
518
519 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
520 Ok(self.fix_content.clone())
521 }
522
523 fn description(&self) -> &'static str {
524 "Mock rule for testing"
525 }
526
527 fn category(&self) -> RuleCategory {
528 RuleCategory::Whitespace
529 }
530
531 fn as_any(&self) -> &dyn std::any::Any {
532 self
533 }
534 }
535
536 #[test]
537 fn test_dependency_ordering() {
538 let coordinator = FixCoordinator::new();
539
540 let rules: Vec<Box<dyn Rule>> = vec![
541 Box::new(MockRule {
542 name: "MD009",
543 warnings: vec![],
544 fix_content: "".to_string(),
545 }),
546 Box::new(MockRule {
547 name: "MD013",
548 warnings: vec![],
549 fix_content: "".to_string(),
550 }),
551 Box::new(MockRule {
552 name: "MD010",
553 warnings: vec![],
554 fix_content: "".to_string(),
555 }),
556 Box::new(MockRule {
557 name: "MD007",
558 warnings: vec![],
559 fix_content: "".to_string(),
560 }),
561 ];
562
563 let ordered = coordinator.get_optimal_order(&rules);
564 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
565
566 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
568 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
569 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
570
571 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
573 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
574 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
575 }
576
577 #[test]
578 fn test_single_rule_fix() {
579 let coordinator = FixCoordinator::new();
580
581 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
583 name: "RemoveBad",
584 check_fn: |content| content.contains("BAD"),
585 fix_fn: |content| content.replace("BAD", "GOOD"),
586 })];
587
588 let mut content = "This is BAD content".to_string();
589 let config = Config::default();
590
591 let result = coordinator
592 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
593 .unwrap();
594
595 assert_eq!(content, "This is GOOD content");
596 assert_eq!(result.rules_fixed, 1);
597 assert!(result.converged);
598 }
599
600 #[test]
601 fn test_cascading_fixes() {
602 let coordinator = FixCoordinator::new();
606
607 let rules: Vec<Box<dyn Rule>> = vec![
608 Box::new(ConditionalFixRule {
609 name: "Rule1_IndentToFence",
610 check_fn: |content| content.contains("INDENT"),
611 fix_fn: |content| content.replace("INDENT", "FENCE"),
612 }),
613 Box::new(ConditionalFixRule {
614 name: "Rule2_FenceToLang",
615 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
616 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
617 }),
618 ];
619
620 let mut content = "Code: INDENT".to_string();
621 let config = Config::default();
622
623 let result = coordinator
624 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
625 .unwrap();
626
627 assert_eq!(content, "Code: FENCE_LANG");
629 assert_eq!(result.rules_fixed, 2);
630 assert!(result.converged);
631 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
632 }
633
634 #[test]
635 fn test_indirect_cascade() {
636 let coordinator = FixCoordinator::new();
641
642 let rules: Vec<Box<dyn Rule>> = vec![
643 Box::new(ConditionalFixRule {
644 name: "Rule1_AddBlank",
645 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
646 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
647 }),
648 Box::new(ConditionalFixRule {
649 name: "Rule2_CodeToFence",
650 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
652 fix_fn: |content| content.replace("CODE", "FENCE"),
653 }),
654 Box::new(ConditionalFixRule {
655 name: "Rule3_AddLang",
656 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
657 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
658 }),
659 ];
660
661 let mut content = "HEADING CODE".to_string();
662 let config = Config::default();
663
664 let result = coordinator
665 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
666 .unwrap();
667
668 assert_eq!(content, "HEADING BLANK FENCE_LANG");
670 assert_eq!(result.rules_fixed, 3);
671 assert!(result.converged);
672 }
673
674 #[test]
675 fn test_unfixable_rules_skipped() {
676 let coordinator = FixCoordinator::new();
677
678 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
679 name: "MD001",
680 check_fn: |content| content.contains("BAD"),
681 fix_fn: |content| content.replace("BAD", "GOOD"),
682 })];
683
684 let mut content = "BAD content".to_string();
685 let mut config = Config::default();
686 config.global.unfixable = vec!["MD001".to_string()];
687
688 let result = coordinator
689 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
690 .unwrap();
691
692 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
694 assert!(result.converged);
695 }
696
697 #[test]
698 fn test_fixable_allowlist() {
699 let coordinator = FixCoordinator::new();
700
701 let rules: Vec<Box<dyn Rule>> = vec![
702 Box::new(ConditionalFixRule {
703 name: "MD001",
704 check_fn: |content| content.contains('A'),
705 fix_fn: |content| content.replace('A', "X"),
706 }),
707 Box::new(ConditionalFixRule {
708 name: "MD002",
709 check_fn: |content| content.contains('B'),
710 fix_fn: |content| content.replace('B', "Y"),
711 }),
712 ];
713
714 let mut content = "AB".to_string();
715 let mut config = Config::default();
716 config.global.fixable = vec!["MD001".to_string()];
717
718 let result = coordinator
719 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
720 .unwrap();
721
722 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
724 }
725
726 #[test]
733 fn test_unfixable_rules_resolved_from_alias() {
734 let coordinator = FixCoordinator::new();
735
736 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
737 name: "MD001",
738 check_fn: |content| content.contains("BAD"),
739 fix_fn: |content| content.replace("BAD", "GOOD"),
740 })];
741
742 let mut content = "BAD content".to_string();
743 let mut config = Config::default();
744 config.global.unfixable = vec!["heading-increment".to_string()];
746 config.canonicalize_rule_lists();
748
749 let result = coordinator
750 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
751 .unwrap();
752
753 assert_eq!(content, "BAD content");
754 assert_eq!(result.rules_fixed, 0);
755 assert!(result.converged);
756 }
757
758 #[test]
762 fn test_fixable_allowlist_resolved_from_alias() {
763 let coordinator = FixCoordinator::new();
764
765 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
766 name: "MD001",
767 check_fn: |content| content.contains("BAD"),
768 fix_fn: |content| content.replace("BAD", "GOOD"),
769 })];
770
771 let mut content = "BAD content".to_string();
772 let mut config = Config::default();
773 config.global.fixable = vec!["heading-increment".to_string()];
774 config.canonicalize_rule_lists();
775
776 let result = coordinator
777 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
778 .unwrap();
779
780 assert_eq!(content, "GOOD content");
781 assert_eq!(result.rules_fixed, 1);
782 }
783
784 #[test]
785 fn test_max_iterations_limit() {
786 let coordinator = FixCoordinator::new();
787
788 static COUNTER: AtomicUsize = AtomicUsize::new(0);
790
791 #[derive(Clone)]
792 struct AlwaysChangeRule;
793 impl Rule for AlwaysChangeRule {
794 fn name(&self) -> &'static str {
795 "AlwaysChange"
796 }
797 fn check(&self, _: &LintContext) -> LintResult {
798 Ok(vec![LintWarning {
799 line: 1,
800 column: 1,
801 end_line: 1,
802 end_column: 1,
803 message: "Always".to_string(),
804 rule_name: Some("AlwaysChange".to_string()),
805 severity: Severity::Error,
806 fix: Some(Fix::new(0..0, String::new())),
807 }])
808 }
809 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
810 COUNTER.fetch_add(1, Ordering::SeqCst);
811 Ok(format!("{}x", ctx.content))
812 }
813 fn description(&self) -> &'static str {
814 "Always changes"
815 }
816 fn category(&self) -> RuleCategory {
817 RuleCategory::Whitespace
818 }
819 fn as_any(&self) -> &dyn std::any::Any {
820 self
821 }
822 }
823
824 COUNTER.store(0, Ordering::SeqCst);
825 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
826
827 let mut content = "test".to_string();
828 let config = Config::default();
829
830 let result = coordinator
831 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
832 .unwrap();
833
834 assert_eq!(result.iterations, 5);
836 assert!(!result.converged);
837 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
838 }
839
840 #[test]
841 fn test_empty_rules() {
842 let coordinator = FixCoordinator::new();
843 let rules: Vec<Box<dyn Rule>> = vec![];
844
845 let mut content = "unchanged".to_string();
846 let config = Config::default();
847
848 let result = coordinator
849 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
850 .unwrap();
851
852 assert_eq!(result.rules_fixed, 0);
853 assert_eq!(result.iterations, 1);
854 assert!(result.converged);
855 assert_eq!(content, "unchanged");
856 }
857
858 #[test]
859 fn test_no_warnings_no_changes() {
860 let coordinator = FixCoordinator::new();
861
862 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
864 name: "NoIssues",
865 check_fn: |_| false, fix_fn: |content| content.to_string(),
867 })];
868
869 let mut content = "clean content".to_string();
870 let config = Config::default();
871
872 let result = coordinator
873 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
874 .unwrap();
875
876 assert_eq!(content, "clean content");
877 assert_eq!(result.rules_fixed, 0);
878 assert!(result.converged);
879 }
880
881 #[test]
882 fn test_oscillation_detection() {
883 let coordinator = FixCoordinator::new();
887
888 let rules: Vec<Box<dyn Rule>> = vec![
889 Box::new(ConditionalFixRule {
890 name: "RuleA",
891 check_fn: |content| content.contains("foo"),
892 fix_fn: |content| content.replace("foo", "bar"),
893 }),
894 Box::new(ConditionalFixRule {
895 name: "RuleB",
896 check_fn: |content| content.contains("bar"),
897 fix_fn: |content| content.replace("bar", "foo"),
898 }),
899 ];
900
901 let mut content = "foo".to_string();
902 let config = Config::default();
903
904 let result = coordinator
905 .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
906 .unwrap();
907
908 assert!(!result.converged, "Should not converge in an oscillating pair");
910 assert!(
911 result.iterations < 10,
912 "Cycle detection should stop well before max_iterations (got {})",
913 result.iterations
914 );
915
916 let mut conflicting = result.conflicting_rules.clone();
918 conflicting.sort();
919 assert_eq!(
920 conflicting,
921 vec!["RuleA".to_string(), "RuleB".to_string()],
922 "Both oscillating rules must be reported"
923 );
924 assert_eq!(
925 result.conflict_cycle,
926 vec!["RuleA".to_string(), "RuleB".to_string()],
927 "Cycle should preserve the observed application order"
928 );
929 }
930
931 #[test]
932 fn test_cyclic_dependencies_handled() {
933 let mut coordinator = FixCoordinator::new();
934
935 coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
937 coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
938 coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
939
940 let rules: Vec<Box<dyn Rule>> = vec![
941 Box::new(MockRule {
942 name: "RuleA",
943 warnings: vec![],
944 fix_content: "".to_string(),
945 }),
946 Box::new(MockRule {
947 name: "RuleB",
948 warnings: vec![],
949 fix_content: "".to_string(),
950 }),
951 Box::new(MockRule {
952 name: "RuleC",
953 warnings: vec![],
954 fix_content: "".to_string(),
955 }),
956 ];
957
958 let ordered = coordinator.get_optimal_order(&rules);
960
961 assert_eq!(ordered.len(), 3);
963 }
964
965 #[test]
966 fn test_fix_is_idempotent() {
967 let coordinator = FixCoordinator::new();
969
970 let rules: Vec<Box<dyn Rule>> = vec![
971 Box::new(ConditionalFixRule {
972 name: "Rule1",
973 check_fn: |content| content.contains('A'),
974 fix_fn: |content| content.replace('A', "B"),
975 }),
976 Box::new(ConditionalFixRule {
977 name: "Rule2",
978 check_fn: |content| content.contains('B') && !content.contains('C'),
979 fix_fn: |content| content.replace('B', "BC"),
980 }),
981 ];
982
983 let config = Config::default();
984
985 let mut content1 = "A".to_string();
987 let result1 = coordinator
988 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
989 .unwrap();
990
991 let mut content2 = content1.clone();
993 let result2 = coordinator
994 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
995 .unwrap();
996
997 assert_eq!(content1, content2);
999 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
1000 assert!(result1.converged);
1001 assert!(result2.converged);
1002 }
1003
1004 #[test]
1005 fn test_apply_fixes_collapses_double_space_without_inline_override() {
1006 let mut content = String::from("`<svg>`. Fortunately\n");
1009 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1010 FixCoordinator::new()
1011 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1012 .unwrap();
1013 assert_eq!(
1014 content, "`<svg>`. Fortunately\n",
1015 "MD064 collapses the sentence double space when not overridden"
1016 );
1017 }
1018
1019 #[test]
1020 fn test_per_file_ignores_skipped_even_with_unfiltered_rules() {
1021 let coordinator = FixCoordinator::new();
1026 let rules: Vec<Box<dyn Rule>> = vec![
1027 Box::new(ConditionalFixRule {
1028 name: "MD004",
1029 check_fn: |c| c.contains('*'),
1030 fix_fn: |c| c.replace('*', "-"),
1031 }),
1032 Box::new(ConditionalFixRule {
1033 name: "MD032",
1034 check_fn: |c| c.contains("PARENT"),
1035 fix_fn: |c| c.replace("PARENT", "parent"),
1036 }),
1037 ];
1038
1039 let mut config = Config::default();
1040 config
1041 .per_file_ignores
1042 .insert("slides/**/*.md".to_string(), vec!["MD004".to_string()]);
1043 config.canonicalize_rule_lists();
1044
1045 let mut content = "* PARENT".to_string();
1047 let result = coordinator
1048 .apply_fixes_iterative(
1049 &rules,
1050 &[],
1051 &mut content,
1052 &config,
1053 10,
1054 Some(std::path::Path::new("slides/deck.md")),
1055 )
1056 .unwrap();
1057 assert_eq!(
1058 content, "* parent",
1059 "MD032 applied, MD004 (`*` -> `-`) skipped for slides/**"
1060 );
1061 assert!(result.fixed_rule_names.contains("MD032"));
1062 assert!(!result.fixed_rule_names.contains("MD004"));
1063
1064 let mut other = "* PARENT".to_string();
1067 coordinator
1068 .apply_fixes_iterative(
1069 &rules,
1070 &[],
1071 &mut other,
1072 &config,
1073 10,
1074 Some(std::path::Path::new("docs/other.md")),
1075 )
1076 .unwrap();
1077 assert_eq!(
1078 other, "- parent",
1079 "both rules apply when the path is not per-file-ignored"
1080 );
1081 }
1082
1083 #[test]
1084 fn test_apply_fixes_honors_inline_configure_file_overrides() {
1085 let mut content = String::from(
1090 "<!-- rumdl-configure-file { \"MD064\": { \"allow-sentence-double-space\": true } } -->\n\n`<svg>`. Fortunately\n",
1091 );
1092 let original = content.clone();
1093 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1094 FixCoordinator::new()
1095 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1096 .unwrap();
1097 assert_eq!(
1098 content, original,
1099 "inline rumdl-configure-file override (allow-sentence-double-space) must prevent the MD064 fix"
1100 );
1101 }
1102}