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 let max_iterations = max_iterations.min(MAX_ITERATIONS);
200
201 let ordered_rules = self.get_optimal_order(rules);
203
204 let mut total_fixed = 0;
205 let mut total_ctx_creations = 0;
206 let mut iterations = 0;
207
208 let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
211
212 let mut fixed_rule_names: HashSet<&str> = HashSet::new();
214
215 let unfixable_rules: HashSet<String> = config.global.unfixable.iter().cloned().collect();
218 let fixable_rules: HashSet<String> = config.global.fixable.iter().cloned().collect();
219 let has_fixable_allowlist = !fixable_rules.is_empty();
220
221 let ignored_for_file: HashSet<String> = file_path
227 .map(|p| config.get_ignored_rules_for_file(p))
228 .unwrap_or_default();
229
230 while iterations < max_iterations {
232 iterations += 1;
233
234 let flavor = file_path.map_or_else(|| config.markdown_flavor(), |p| config.get_flavor_for_file(p));
237 let ctx = LintContext::new(content, flavor, file_path.map(std::path::Path::to_path_buf));
238 total_ctx_creations += 1;
239
240 let recreated_rules: HashMap<String, Box<dyn Rule>> = {
246 let inline_overrides = ctx.inline_config().get_all_rule_configs();
247 if inline_overrides.is_empty() {
248 HashMap::new()
249 } else {
250 let merged = config.merge_with_inline_config(ctx.inline_config());
251 inline_overrides
252 .keys()
253 .filter_map(|name| {
254 crate::rules::create_rule_by_name(name, &merged).map(|rule| (name.clone(), rule))
255 })
256 .collect()
257 }
258 };
259
260 let mut any_fix_applied = false;
261 let mut this_iter_rule: &str = "";
263
264 for rule in &ordered_rules {
266 if unfixable_rules.contains(rule.name()) {
268 continue;
269 }
270 if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
271 continue;
272 }
273 if ignored_for_file.contains(rule.name()) {
275 continue;
276 }
277
278 let effective_rule: &dyn Rule = recreated_rules.get(rule.name()).map_or(*rule, |r| r.as_ref());
282
283 if effective_rule.should_skip(&ctx) {
285 continue;
286 }
287
288 let Ok(warnings) = effective_rule.check(&ctx) else {
290 continue;
291 };
292
293 if warnings.is_empty() {
294 continue;
295 }
296
297 let inline_config = ctx.inline_config();
299 let filtered_warnings =
300 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
301
302 if filtered_warnings.is_empty() {
303 continue;
304 }
305
306 let has_inline_fix = filtered_warnings.iter().any(|w| w.fix.is_some());
317 let rule_advertises_fix = effective_rule.fix_capability() != FixCapability::Unfixable;
318 if !has_inline_fix && !rule_advertises_fix {
319 continue;
320 }
321
322 match effective_rule.fix(&ctx) {
324 Ok(fixed_content) => {
325 if fixed_content != *content {
326 *content = fixed_content;
327 total_fixed += 1;
328 any_fix_applied = true;
329 this_iter_rule = rule.name();
330 fixed_rule_names.insert(rule.name());
331
332 break;
336 }
337 }
338 Err(_) => {
339 continue;
341 }
342 }
343 }
344
345 let current_hash = hash_content(content);
346
347 if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
349 if cycle_start == history.len() - 1 {
350 return Ok(FixResult {
352 rules_fixed: total_fixed,
353 iterations,
354 context_creations: total_ctx_creations,
355 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
356 converged: true,
357 conflicting_rules: Vec::new(),
358 conflict_cycle: Vec::new(),
359 });
360 } else {
361 let conflict_cycle: Vec<String> = history[cycle_start + 1..]
364 .iter()
365 .map(|(_, r)| r.to_string())
366 .chain(std::iter::once(this_iter_rule.to_string()))
367 .filter(|r| !r.is_empty())
368 .collect();
369 let conflicting_rules: Vec<String> = history[cycle_start + 1..]
370 .iter()
371 .map(|(_, r)| *r)
372 .chain(std::iter::once(this_iter_rule))
373 .filter(|r| !r.is_empty())
374 .collect::<HashSet<&str>>()
375 .into_iter()
376 .map(std::string::ToString::to_string)
377 .collect();
378 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: false,
384 conflicting_rules,
385 conflict_cycle,
386 });
387 }
388 }
389
390 history.push((current_hash, this_iter_rule));
392
393 if !any_fix_applied {
395 return Ok(FixResult {
396 rules_fixed: total_fixed,
397 iterations,
398 context_creations: total_ctx_creations,
399 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
400 converged: true,
401 conflicting_rules: Vec::new(),
402 conflict_cycle: Vec::new(),
403 });
404 }
405 }
406
407 Ok(FixResult {
409 rules_fixed: total_fixed,
410 iterations,
411 context_creations: total_ctx_creations,
412 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
413 converged: false,
414 conflicting_rules: Vec::new(),
415 conflict_cycle: Vec::new(),
416 })
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
424 use std::sync::atomic::{AtomicUsize, Ordering};
425
426 #[derive(Clone)]
428 struct ConditionalFixRule {
429 name: &'static str,
430 check_fn: fn(&str) -> bool,
432 fix_fn: fn(&str) -> String,
434 }
435
436 impl Rule for ConditionalFixRule {
437 fn name(&self) -> &'static str {
438 self.name
439 }
440
441 fn check(&self, ctx: &LintContext) -> LintResult {
442 if (self.check_fn)(ctx.content) {
443 Ok(vec![LintWarning {
444 line: 1,
445 column: 1,
446 end_line: 1,
447 end_column: 1,
448 message: format!("{} issue found", self.name),
449 rule_name: Some(self.name.to_string()),
450 severity: Severity::Error,
451 fix: Some(Fix::new(0..0, String::new())),
452 }])
453 } else {
454 Ok(vec![])
455 }
456 }
457
458 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
459 Ok((self.fix_fn)(ctx.content))
460 }
461
462 fn description(&self) -> &'static str {
463 "Conditional fix rule for testing"
464 }
465
466 fn category(&self) -> RuleCategory {
467 RuleCategory::Whitespace
468 }
469
470 fn as_any(&self) -> &dyn std::any::Any {
471 self
472 }
473 }
474
475 #[derive(Clone)]
477 struct MockRule {
478 name: &'static str,
479 warnings: Vec<LintWarning>,
480 fix_content: String,
481 }
482
483 impl Rule for MockRule {
484 fn name(&self) -> &'static str {
485 self.name
486 }
487
488 fn check(&self, _ctx: &LintContext) -> LintResult {
489 Ok(self.warnings.clone())
490 }
491
492 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
493 Ok(self.fix_content.clone())
494 }
495
496 fn description(&self) -> &'static str {
497 "Mock rule for testing"
498 }
499
500 fn category(&self) -> RuleCategory {
501 RuleCategory::Whitespace
502 }
503
504 fn as_any(&self) -> &dyn std::any::Any {
505 self
506 }
507 }
508
509 #[test]
510 fn test_dependency_ordering() {
511 let coordinator = FixCoordinator::new();
512
513 let rules: Vec<Box<dyn Rule>> = vec![
514 Box::new(MockRule {
515 name: "MD009",
516 warnings: vec![],
517 fix_content: "".to_string(),
518 }),
519 Box::new(MockRule {
520 name: "MD013",
521 warnings: vec![],
522 fix_content: "".to_string(),
523 }),
524 Box::new(MockRule {
525 name: "MD010",
526 warnings: vec![],
527 fix_content: "".to_string(),
528 }),
529 Box::new(MockRule {
530 name: "MD007",
531 warnings: vec![],
532 fix_content: "".to_string(),
533 }),
534 ];
535
536 let ordered = coordinator.get_optimal_order(&rules);
537 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
538
539 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
541 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
542 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
543
544 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
546 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
547 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
548 }
549
550 #[test]
551 fn test_single_rule_fix() {
552 let coordinator = FixCoordinator::new();
553
554 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
556 name: "RemoveBad",
557 check_fn: |content| content.contains("BAD"),
558 fix_fn: |content| content.replace("BAD", "GOOD"),
559 })];
560
561 let mut content = "This is BAD content".to_string();
562 let config = Config::default();
563
564 let result = coordinator
565 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
566 .unwrap();
567
568 assert_eq!(content, "This is GOOD content");
569 assert_eq!(result.rules_fixed, 1);
570 assert!(result.converged);
571 }
572
573 #[test]
574 fn test_cascading_fixes() {
575 let coordinator = FixCoordinator::new();
579
580 let rules: Vec<Box<dyn Rule>> = vec![
581 Box::new(ConditionalFixRule {
582 name: "Rule1_IndentToFence",
583 check_fn: |content| content.contains("INDENT"),
584 fix_fn: |content| content.replace("INDENT", "FENCE"),
585 }),
586 Box::new(ConditionalFixRule {
587 name: "Rule2_FenceToLang",
588 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
589 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
590 }),
591 ];
592
593 let mut content = "Code: INDENT".to_string();
594 let config = Config::default();
595
596 let result = coordinator
597 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
598 .unwrap();
599
600 assert_eq!(content, "Code: FENCE_LANG");
602 assert_eq!(result.rules_fixed, 2);
603 assert!(result.converged);
604 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
605 }
606
607 #[test]
608 fn test_indirect_cascade() {
609 let coordinator = FixCoordinator::new();
614
615 let rules: Vec<Box<dyn Rule>> = vec![
616 Box::new(ConditionalFixRule {
617 name: "Rule1_AddBlank",
618 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
619 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
620 }),
621 Box::new(ConditionalFixRule {
622 name: "Rule2_CodeToFence",
623 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
625 fix_fn: |content| content.replace("CODE", "FENCE"),
626 }),
627 Box::new(ConditionalFixRule {
628 name: "Rule3_AddLang",
629 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
630 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
631 }),
632 ];
633
634 let mut content = "HEADING CODE".to_string();
635 let config = Config::default();
636
637 let result = coordinator
638 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
639 .unwrap();
640
641 assert_eq!(content, "HEADING BLANK FENCE_LANG");
643 assert_eq!(result.rules_fixed, 3);
644 assert!(result.converged);
645 }
646
647 #[test]
648 fn test_unfixable_rules_skipped() {
649 let coordinator = FixCoordinator::new();
650
651 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
652 name: "MD001",
653 check_fn: |content| content.contains("BAD"),
654 fix_fn: |content| content.replace("BAD", "GOOD"),
655 })];
656
657 let mut content = "BAD content".to_string();
658 let mut config = Config::default();
659 config.global.unfixable = vec!["MD001".to_string()];
660
661 let result = coordinator
662 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
663 .unwrap();
664
665 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
667 assert!(result.converged);
668 }
669
670 #[test]
671 fn test_fixable_allowlist() {
672 let coordinator = FixCoordinator::new();
673
674 let rules: Vec<Box<dyn Rule>> = vec![
675 Box::new(ConditionalFixRule {
676 name: "MD001",
677 check_fn: |content| content.contains('A'),
678 fix_fn: |content| content.replace('A', "X"),
679 }),
680 Box::new(ConditionalFixRule {
681 name: "MD002",
682 check_fn: |content| content.contains('B'),
683 fix_fn: |content| content.replace('B', "Y"),
684 }),
685 ];
686
687 let mut content = "AB".to_string();
688 let mut config = Config::default();
689 config.global.fixable = vec!["MD001".to_string()];
690
691 let result = coordinator
692 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
693 .unwrap();
694
695 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
697 }
698
699 #[test]
706 fn test_unfixable_rules_resolved_from_alias() {
707 let coordinator = FixCoordinator::new();
708
709 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
710 name: "MD001",
711 check_fn: |content| content.contains("BAD"),
712 fix_fn: |content| content.replace("BAD", "GOOD"),
713 })];
714
715 let mut content = "BAD content".to_string();
716 let mut config = Config::default();
717 config.global.unfixable = vec!["heading-increment".to_string()];
719 config.canonicalize_rule_lists();
721
722 let result = coordinator
723 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
724 .unwrap();
725
726 assert_eq!(content, "BAD content");
727 assert_eq!(result.rules_fixed, 0);
728 assert!(result.converged);
729 }
730
731 #[test]
735 fn test_fixable_allowlist_resolved_from_alias() {
736 let coordinator = FixCoordinator::new();
737
738 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
739 name: "MD001",
740 check_fn: |content| content.contains("BAD"),
741 fix_fn: |content| content.replace("BAD", "GOOD"),
742 })];
743
744 let mut content = "BAD content".to_string();
745 let mut config = Config::default();
746 config.global.fixable = vec!["heading-increment".to_string()];
747 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, "GOOD content");
754 assert_eq!(result.rules_fixed, 1);
755 }
756
757 #[test]
758 fn test_max_iterations_limit() {
759 let coordinator = FixCoordinator::new();
760
761 static COUNTER: AtomicUsize = AtomicUsize::new(0);
763
764 #[derive(Clone)]
765 struct AlwaysChangeRule;
766 impl Rule for AlwaysChangeRule {
767 fn name(&self) -> &'static str {
768 "AlwaysChange"
769 }
770 fn check(&self, _: &LintContext) -> LintResult {
771 Ok(vec![LintWarning {
772 line: 1,
773 column: 1,
774 end_line: 1,
775 end_column: 1,
776 message: "Always".to_string(),
777 rule_name: Some("AlwaysChange".to_string()),
778 severity: Severity::Error,
779 fix: Some(Fix::new(0..0, String::new())),
780 }])
781 }
782 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
783 COUNTER.fetch_add(1, Ordering::SeqCst);
784 Ok(format!("{}x", ctx.content))
785 }
786 fn description(&self) -> &'static str {
787 "Always changes"
788 }
789 fn category(&self) -> RuleCategory {
790 RuleCategory::Whitespace
791 }
792 fn as_any(&self) -> &dyn std::any::Any {
793 self
794 }
795 }
796
797 COUNTER.store(0, Ordering::SeqCst);
798 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
799
800 let mut content = "test".to_string();
801 let config = Config::default();
802
803 let result = coordinator
804 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
805 .unwrap();
806
807 assert_eq!(result.iterations, 5);
809 assert!(!result.converged);
810 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
811 }
812
813 #[test]
814 fn test_empty_rules() {
815 let coordinator = FixCoordinator::new();
816 let rules: Vec<Box<dyn Rule>> = vec![];
817
818 let mut content = "unchanged".to_string();
819 let config = Config::default();
820
821 let result = coordinator
822 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
823 .unwrap();
824
825 assert_eq!(result.rules_fixed, 0);
826 assert_eq!(result.iterations, 1);
827 assert!(result.converged);
828 assert_eq!(content, "unchanged");
829 }
830
831 #[test]
832 fn test_no_warnings_no_changes() {
833 let coordinator = FixCoordinator::new();
834
835 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
837 name: "NoIssues",
838 check_fn: |_| false, fix_fn: |content| content.to_string(),
840 })];
841
842 let mut content = "clean content".to_string();
843 let config = Config::default();
844
845 let result = coordinator
846 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
847 .unwrap();
848
849 assert_eq!(content, "clean content");
850 assert_eq!(result.rules_fixed, 0);
851 assert!(result.converged);
852 }
853
854 #[test]
855 fn test_oscillation_detection() {
856 let coordinator = FixCoordinator::new();
860
861 let rules: Vec<Box<dyn Rule>> = vec![
862 Box::new(ConditionalFixRule {
863 name: "RuleA",
864 check_fn: |content| content.contains("foo"),
865 fix_fn: |content| content.replace("foo", "bar"),
866 }),
867 Box::new(ConditionalFixRule {
868 name: "RuleB",
869 check_fn: |content| content.contains("bar"),
870 fix_fn: |content| content.replace("bar", "foo"),
871 }),
872 ];
873
874 let mut content = "foo".to_string();
875 let config = Config::default();
876
877 let result = coordinator
878 .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
879 .unwrap();
880
881 assert!(!result.converged, "Should not converge in an oscillating pair");
883 assert!(
884 result.iterations < 10,
885 "Cycle detection should stop well before max_iterations (got {})",
886 result.iterations
887 );
888
889 let mut conflicting = result.conflicting_rules.clone();
891 conflicting.sort();
892 assert_eq!(
893 conflicting,
894 vec!["RuleA".to_string(), "RuleB".to_string()],
895 "Both oscillating rules must be reported"
896 );
897 assert_eq!(
898 result.conflict_cycle,
899 vec!["RuleA".to_string(), "RuleB".to_string()],
900 "Cycle should preserve the observed application order"
901 );
902 }
903
904 #[test]
905 fn test_cyclic_dependencies_handled() {
906 let mut coordinator = FixCoordinator::new();
907
908 coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
910 coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
911 coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
912
913 let rules: Vec<Box<dyn Rule>> = vec![
914 Box::new(MockRule {
915 name: "RuleA",
916 warnings: vec![],
917 fix_content: "".to_string(),
918 }),
919 Box::new(MockRule {
920 name: "RuleB",
921 warnings: vec![],
922 fix_content: "".to_string(),
923 }),
924 Box::new(MockRule {
925 name: "RuleC",
926 warnings: vec![],
927 fix_content: "".to_string(),
928 }),
929 ];
930
931 let ordered = coordinator.get_optimal_order(&rules);
933
934 assert_eq!(ordered.len(), 3);
936 }
937
938 #[test]
939 fn test_fix_is_idempotent() {
940 let coordinator = FixCoordinator::new();
942
943 let rules: Vec<Box<dyn Rule>> = vec![
944 Box::new(ConditionalFixRule {
945 name: "Rule1",
946 check_fn: |content| content.contains('A'),
947 fix_fn: |content| content.replace('A', "B"),
948 }),
949 Box::new(ConditionalFixRule {
950 name: "Rule2",
951 check_fn: |content| content.contains('B') && !content.contains('C'),
952 fix_fn: |content| content.replace('B', "BC"),
953 }),
954 ];
955
956 let config = Config::default();
957
958 let mut content1 = "A".to_string();
960 let result1 = coordinator
961 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
962 .unwrap();
963
964 let mut content2 = content1.clone();
966 let result2 = coordinator
967 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
968 .unwrap();
969
970 assert_eq!(content1, content2);
972 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
973 assert!(result1.converged);
974 assert!(result2.converged);
975 }
976
977 #[test]
978 fn test_apply_fixes_collapses_double_space_without_inline_override() {
979 let mut content = String::from("`<svg>`. Fortunately\n");
982 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
983 FixCoordinator::new()
984 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
985 .unwrap();
986 assert_eq!(
987 content, "`<svg>`. Fortunately\n",
988 "MD064 collapses the sentence double space when not overridden"
989 );
990 }
991
992 #[test]
993 fn test_per_file_ignores_skipped_even_with_unfiltered_rules() {
994 let coordinator = FixCoordinator::new();
999 let rules: Vec<Box<dyn Rule>> = vec![
1000 Box::new(ConditionalFixRule {
1001 name: "MD004",
1002 check_fn: |c| c.contains('*'),
1003 fix_fn: |c| c.replace('*', "-"),
1004 }),
1005 Box::new(ConditionalFixRule {
1006 name: "MD032",
1007 check_fn: |c| c.contains("PARENT"),
1008 fix_fn: |c| c.replace("PARENT", "parent"),
1009 }),
1010 ];
1011
1012 let mut config = Config::default();
1013 config
1014 .per_file_ignores
1015 .insert("slides/**/*.md".to_string(), vec!["MD004".to_string()]);
1016 config.canonicalize_rule_lists();
1017
1018 let mut content = "* PARENT".to_string();
1020 let result = coordinator
1021 .apply_fixes_iterative(
1022 &rules,
1023 &[],
1024 &mut content,
1025 &config,
1026 10,
1027 Some(std::path::Path::new("slides/deck.md")),
1028 )
1029 .unwrap();
1030 assert_eq!(
1031 content, "* parent",
1032 "MD032 applied, MD004 (`*` -> `-`) skipped for slides/**"
1033 );
1034 assert!(result.fixed_rule_names.contains("MD032"));
1035 assert!(!result.fixed_rule_names.contains("MD004"));
1036
1037 let mut other = "* PARENT".to_string();
1040 coordinator
1041 .apply_fixes_iterative(
1042 &rules,
1043 &[],
1044 &mut other,
1045 &config,
1046 10,
1047 Some(std::path::Path::new("docs/other.md")),
1048 )
1049 .unwrap();
1050 assert_eq!(
1051 other, "- parent",
1052 "both rules apply when the path is not per-file-ignored"
1053 );
1054 }
1055
1056 #[test]
1057 fn test_apply_fixes_honors_inline_configure_file_overrides() {
1058 let mut content = String::from(
1063 "<!-- rumdl-configure-file { \"MD064\": { \"allow-sentence-double-space\": true } } -->\n\n`<svg>`. Fortunately\n",
1064 );
1065 let original = content.clone();
1066 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
1067 FixCoordinator::new()
1068 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
1069 .unwrap();
1070 assert_eq!(
1071 content, original,
1072 "inline rumdl-configure-file override (allow-sentence-double-space) must prevent the MD064 fix"
1073 );
1074 }
1075}