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 while iterations < max_iterations {
223 iterations += 1;
224
225 let flavor = file_path.map_or_else(|| config.markdown_flavor(), |p| config.get_flavor_for_file(p));
228 let ctx = LintContext::new(content, flavor, file_path.map(std::path::Path::to_path_buf));
229 total_ctx_creations += 1;
230
231 let recreated_rules: HashMap<String, Box<dyn Rule>> = {
237 let inline_overrides = ctx.inline_config().get_all_rule_configs();
238 if inline_overrides.is_empty() {
239 HashMap::new()
240 } else {
241 let merged = config.merge_with_inline_config(ctx.inline_config());
242 inline_overrides
243 .keys()
244 .filter_map(|name| {
245 crate::rules::create_rule_by_name(name, &merged).map(|rule| (name.clone(), rule))
246 })
247 .collect()
248 }
249 };
250
251 let mut any_fix_applied = false;
252 let mut this_iter_rule: &str = "";
254
255 for rule in &ordered_rules {
257 if unfixable_rules.contains(rule.name()) {
259 continue;
260 }
261 if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
262 continue;
263 }
264
265 let effective_rule: &dyn Rule = recreated_rules.get(rule.name()).map_or(*rule, |r| r.as_ref());
269
270 if effective_rule.should_skip(&ctx) {
272 continue;
273 }
274
275 let Ok(warnings) = effective_rule.check(&ctx) else {
277 continue;
278 };
279
280 if warnings.is_empty() {
281 continue;
282 }
283
284 let inline_config = ctx.inline_config();
286 let filtered_warnings =
287 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
288
289 if filtered_warnings.is_empty() {
290 continue;
291 }
292
293 let has_inline_fix = filtered_warnings.iter().any(|w| w.fix.is_some());
304 let rule_advertises_fix = effective_rule.fix_capability() != FixCapability::Unfixable;
305 if !has_inline_fix && !rule_advertises_fix {
306 continue;
307 }
308
309 match effective_rule.fix(&ctx) {
311 Ok(fixed_content) => {
312 if fixed_content != *content {
313 *content = fixed_content;
314 total_fixed += 1;
315 any_fix_applied = true;
316 this_iter_rule = rule.name();
317 fixed_rule_names.insert(rule.name());
318
319 break;
323 }
324 }
325 Err(_) => {
326 continue;
328 }
329 }
330 }
331
332 let current_hash = hash_content(content);
333
334 if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
336 if cycle_start == history.len() - 1 {
337 return Ok(FixResult {
339 rules_fixed: total_fixed,
340 iterations,
341 context_creations: total_ctx_creations,
342 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
343 converged: true,
344 conflicting_rules: Vec::new(),
345 conflict_cycle: Vec::new(),
346 });
347 } else {
348 let conflict_cycle: Vec<String> = history[cycle_start + 1..]
351 .iter()
352 .map(|(_, r)| r.to_string())
353 .chain(std::iter::once(this_iter_rule.to_string()))
354 .filter(|r| !r.is_empty())
355 .collect();
356 let conflicting_rules: Vec<String> = history[cycle_start + 1..]
357 .iter()
358 .map(|(_, r)| *r)
359 .chain(std::iter::once(this_iter_rule))
360 .filter(|r| !r.is_empty())
361 .collect::<HashSet<&str>>()
362 .into_iter()
363 .map(std::string::ToString::to_string)
364 .collect();
365 return Ok(FixResult {
366 rules_fixed: total_fixed,
367 iterations,
368 context_creations: total_ctx_creations,
369 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
370 converged: false,
371 conflicting_rules,
372 conflict_cycle,
373 });
374 }
375 }
376
377 history.push((current_hash, this_iter_rule));
379
380 if !any_fix_applied {
382 return Ok(FixResult {
383 rules_fixed: total_fixed,
384 iterations,
385 context_creations: total_ctx_creations,
386 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
387 converged: true,
388 conflicting_rules: Vec::new(),
389 conflict_cycle: Vec::new(),
390 });
391 }
392 }
393
394 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: false,
401 conflicting_rules: Vec::new(),
402 conflict_cycle: Vec::new(),
403 })
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
411 use std::sync::atomic::{AtomicUsize, Ordering};
412
413 #[derive(Clone)]
415 struct ConditionalFixRule {
416 name: &'static str,
417 check_fn: fn(&str) -> bool,
419 fix_fn: fn(&str) -> String,
421 }
422
423 impl Rule for ConditionalFixRule {
424 fn name(&self) -> &'static str {
425 self.name
426 }
427
428 fn check(&self, ctx: &LintContext) -> LintResult {
429 if (self.check_fn)(ctx.content) {
430 Ok(vec![LintWarning {
431 line: 1,
432 column: 1,
433 end_line: 1,
434 end_column: 1,
435 message: format!("{} issue found", self.name),
436 rule_name: Some(self.name.to_string()),
437 severity: Severity::Error,
438 fix: Some(Fix::new(0..0, String::new())),
439 }])
440 } else {
441 Ok(vec![])
442 }
443 }
444
445 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
446 Ok((self.fix_fn)(ctx.content))
447 }
448
449 fn description(&self) -> &'static str {
450 "Conditional fix rule for testing"
451 }
452
453 fn category(&self) -> RuleCategory {
454 RuleCategory::Whitespace
455 }
456
457 fn as_any(&self) -> &dyn std::any::Any {
458 self
459 }
460 }
461
462 #[derive(Clone)]
464 struct MockRule {
465 name: &'static str,
466 warnings: Vec<LintWarning>,
467 fix_content: String,
468 }
469
470 impl Rule for MockRule {
471 fn name(&self) -> &'static str {
472 self.name
473 }
474
475 fn check(&self, _ctx: &LintContext) -> LintResult {
476 Ok(self.warnings.clone())
477 }
478
479 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
480 Ok(self.fix_content.clone())
481 }
482
483 fn description(&self) -> &'static str {
484 "Mock rule for testing"
485 }
486
487 fn category(&self) -> RuleCategory {
488 RuleCategory::Whitespace
489 }
490
491 fn as_any(&self) -> &dyn std::any::Any {
492 self
493 }
494 }
495
496 #[test]
497 fn test_dependency_ordering() {
498 let coordinator = FixCoordinator::new();
499
500 let rules: Vec<Box<dyn Rule>> = vec![
501 Box::new(MockRule {
502 name: "MD009",
503 warnings: vec![],
504 fix_content: "".to_string(),
505 }),
506 Box::new(MockRule {
507 name: "MD013",
508 warnings: vec![],
509 fix_content: "".to_string(),
510 }),
511 Box::new(MockRule {
512 name: "MD010",
513 warnings: vec![],
514 fix_content: "".to_string(),
515 }),
516 Box::new(MockRule {
517 name: "MD007",
518 warnings: vec![],
519 fix_content: "".to_string(),
520 }),
521 ];
522
523 let ordered = coordinator.get_optimal_order(&rules);
524 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
525
526 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
528 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
529 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
530
531 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
533 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
534 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
535 }
536
537 #[test]
538 fn test_single_rule_fix() {
539 let coordinator = FixCoordinator::new();
540
541 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
543 name: "RemoveBad",
544 check_fn: |content| content.contains("BAD"),
545 fix_fn: |content| content.replace("BAD", "GOOD"),
546 })];
547
548 let mut content = "This is BAD content".to_string();
549 let config = Config::default();
550
551 let result = coordinator
552 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
553 .unwrap();
554
555 assert_eq!(content, "This is GOOD content");
556 assert_eq!(result.rules_fixed, 1);
557 assert!(result.converged);
558 }
559
560 #[test]
561 fn test_cascading_fixes() {
562 let coordinator = FixCoordinator::new();
566
567 let rules: Vec<Box<dyn Rule>> = vec![
568 Box::new(ConditionalFixRule {
569 name: "Rule1_IndentToFence",
570 check_fn: |content| content.contains("INDENT"),
571 fix_fn: |content| content.replace("INDENT", "FENCE"),
572 }),
573 Box::new(ConditionalFixRule {
574 name: "Rule2_FenceToLang",
575 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
576 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
577 }),
578 ];
579
580 let mut content = "Code: INDENT".to_string();
581 let config = Config::default();
582
583 let result = coordinator
584 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
585 .unwrap();
586
587 assert_eq!(content, "Code: FENCE_LANG");
589 assert_eq!(result.rules_fixed, 2);
590 assert!(result.converged);
591 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
592 }
593
594 #[test]
595 fn test_indirect_cascade() {
596 let coordinator = FixCoordinator::new();
601
602 let rules: Vec<Box<dyn Rule>> = vec![
603 Box::new(ConditionalFixRule {
604 name: "Rule1_AddBlank",
605 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
606 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
607 }),
608 Box::new(ConditionalFixRule {
609 name: "Rule2_CodeToFence",
610 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
612 fix_fn: |content| content.replace("CODE", "FENCE"),
613 }),
614 Box::new(ConditionalFixRule {
615 name: "Rule3_AddLang",
616 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
617 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
618 }),
619 ];
620
621 let mut content = "HEADING CODE".to_string();
622 let config = Config::default();
623
624 let result = coordinator
625 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
626 .unwrap();
627
628 assert_eq!(content, "HEADING BLANK FENCE_LANG");
630 assert_eq!(result.rules_fixed, 3);
631 assert!(result.converged);
632 }
633
634 #[test]
635 fn test_unfixable_rules_skipped() {
636 let coordinator = FixCoordinator::new();
637
638 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
639 name: "MD001",
640 check_fn: |content| content.contains("BAD"),
641 fix_fn: |content| content.replace("BAD", "GOOD"),
642 })];
643
644 let mut content = "BAD content".to_string();
645 let mut config = Config::default();
646 config.global.unfixable = vec!["MD001".to_string()];
647
648 let result = coordinator
649 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
650 .unwrap();
651
652 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
654 assert!(result.converged);
655 }
656
657 #[test]
658 fn test_fixable_allowlist() {
659 let coordinator = FixCoordinator::new();
660
661 let rules: Vec<Box<dyn Rule>> = vec![
662 Box::new(ConditionalFixRule {
663 name: "MD001",
664 check_fn: |content| content.contains('A'),
665 fix_fn: |content| content.replace('A', "X"),
666 }),
667 Box::new(ConditionalFixRule {
668 name: "MD002",
669 check_fn: |content| content.contains('B'),
670 fix_fn: |content| content.replace('B', "Y"),
671 }),
672 ];
673
674 let mut content = "AB".to_string();
675 let mut config = Config::default();
676 config.global.fixable = vec!["MD001".to_string()];
677
678 let result = coordinator
679 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
680 .unwrap();
681
682 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
684 }
685
686 #[test]
693 fn test_unfixable_rules_resolved_from_alias() {
694 let coordinator = FixCoordinator::new();
695
696 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
697 name: "MD001",
698 check_fn: |content| content.contains("BAD"),
699 fix_fn: |content| content.replace("BAD", "GOOD"),
700 })];
701
702 let mut content = "BAD content".to_string();
703 let mut config = Config::default();
704 config.global.unfixable = vec!["heading-increment".to_string()];
706 config.canonicalize_rule_lists();
708
709 let result = coordinator
710 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
711 .unwrap();
712
713 assert_eq!(content, "BAD content");
714 assert_eq!(result.rules_fixed, 0);
715 assert!(result.converged);
716 }
717
718 #[test]
722 fn test_fixable_allowlist_resolved_from_alias() {
723 let coordinator = FixCoordinator::new();
724
725 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
726 name: "MD001",
727 check_fn: |content| content.contains("BAD"),
728 fix_fn: |content| content.replace("BAD", "GOOD"),
729 })];
730
731 let mut content = "BAD content".to_string();
732 let mut config = Config::default();
733 config.global.fixable = vec!["heading-increment".to_string()];
734 config.canonicalize_rule_lists();
735
736 let result = coordinator
737 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
738 .unwrap();
739
740 assert_eq!(content, "GOOD content");
741 assert_eq!(result.rules_fixed, 1);
742 }
743
744 #[test]
745 fn test_max_iterations_limit() {
746 let coordinator = FixCoordinator::new();
747
748 static COUNTER: AtomicUsize = AtomicUsize::new(0);
750
751 #[derive(Clone)]
752 struct AlwaysChangeRule;
753 impl Rule for AlwaysChangeRule {
754 fn name(&self) -> &'static str {
755 "AlwaysChange"
756 }
757 fn check(&self, _: &LintContext) -> LintResult {
758 Ok(vec![LintWarning {
759 line: 1,
760 column: 1,
761 end_line: 1,
762 end_column: 1,
763 message: "Always".to_string(),
764 rule_name: Some("AlwaysChange".to_string()),
765 severity: Severity::Error,
766 fix: Some(Fix::new(0..0, String::new())),
767 }])
768 }
769 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
770 COUNTER.fetch_add(1, Ordering::SeqCst);
771 Ok(format!("{}x", ctx.content))
772 }
773 fn description(&self) -> &'static str {
774 "Always changes"
775 }
776 fn category(&self) -> RuleCategory {
777 RuleCategory::Whitespace
778 }
779 fn as_any(&self) -> &dyn std::any::Any {
780 self
781 }
782 }
783
784 COUNTER.store(0, Ordering::SeqCst);
785 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
786
787 let mut content = "test".to_string();
788 let config = Config::default();
789
790 let result = coordinator
791 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
792 .unwrap();
793
794 assert_eq!(result.iterations, 5);
796 assert!(!result.converged);
797 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
798 }
799
800 #[test]
801 fn test_empty_rules() {
802 let coordinator = FixCoordinator::new();
803 let rules: Vec<Box<dyn Rule>> = vec![];
804
805 let mut content = "unchanged".to_string();
806 let config = Config::default();
807
808 let result = coordinator
809 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
810 .unwrap();
811
812 assert_eq!(result.rules_fixed, 0);
813 assert_eq!(result.iterations, 1);
814 assert!(result.converged);
815 assert_eq!(content, "unchanged");
816 }
817
818 #[test]
819 fn test_no_warnings_no_changes() {
820 let coordinator = FixCoordinator::new();
821
822 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
824 name: "NoIssues",
825 check_fn: |_| false, fix_fn: |content| content.to_string(),
827 })];
828
829 let mut content = "clean content".to_string();
830 let config = Config::default();
831
832 let result = coordinator
833 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
834 .unwrap();
835
836 assert_eq!(content, "clean content");
837 assert_eq!(result.rules_fixed, 0);
838 assert!(result.converged);
839 }
840
841 #[test]
842 fn test_oscillation_detection() {
843 let coordinator = FixCoordinator::new();
847
848 let rules: Vec<Box<dyn Rule>> = vec![
849 Box::new(ConditionalFixRule {
850 name: "RuleA",
851 check_fn: |content| content.contains("foo"),
852 fix_fn: |content| content.replace("foo", "bar"),
853 }),
854 Box::new(ConditionalFixRule {
855 name: "RuleB",
856 check_fn: |content| content.contains("bar"),
857 fix_fn: |content| content.replace("bar", "foo"),
858 }),
859 ];
860
861 let mut content = "foo".to_string();
862 let config = Config::default();
863
864 let result = coordinator
865 .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
866 .unwrap();
867
868 assert!(!result.converged, "Should not converge in an oscillating pair");
870 assert!(
871 result.iterations < 10,
872 "Cycle detection should stop well before max_iterations (got {})",
873 result.iterations
874 );
875
876 let mut conflicting = result.conflicting_rules.clone();
878 conflicting.sort();
879 assert_eq!(
880 conflicting,
881 vec!["RuleA".to_string(), "RuleB".to_string()],
882 "Both oscillating rules must be reported"
883 );
884 assert_eq!(
885 result.conflict_cycle,
886 vec!["RuleA".to_string(), "RuleB".to_string()],
887 "Cycle should preserve the observed application order"
888 );
889 }
890
891 #[test]
892 fn test_cyclic_dependencies_handled() {
893 let mut coordinator = FixCoordinator::new();
894
895 coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
897 coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
898 coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
899
900 let rules: Vec<Box<dyn Rule>> = vec![
901 Box::new(MockRule {
902 name: "RuleA",
903 warnings: vec![],
904 fix_content: "".to_string(),
905 }),
906 Box::new(MockRule {
907 name: "RuleB",
908 warnings: vec![],
909 fix_content: "".to_string(),
910 }),
911 Box::new(MockRule {
912 name: "RuleC",
913 warnings: vec![],
914 fix_content: "".to_string(),
915 }),
916 ];
917
918 let ordered = coordinator.get_optimal_order(&rules);
920
921 assert_eq!(ordered.len(), 3);
923 }
924
925 #[test]
926 fn test_fix_is_idempotent() {
927 let coordinator = FixCoordinator::new();
929
930 let rules: Vec<Box<dyn Rule>> = vec![
931 Box::new(ConditionalFixRule {
932 name: "Rule1",
933 check_fn: |content| content.contains('A'),
934 fix_fn: |content| content.replace('A', "B"),
935 }),
936 Box::new(ConditionalFixRule {
937 name: "Rule2",
938 check_fn: |content| content.contains('B') && !content.contains('C'),
939 fix_fn: |content| content.replace('B', "BC"),
940 }),
941 ];
942
943 let config = Config::default();
944
945 let mut content1 = "A".to_string();
947 let result1 = coordinator
948 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
949 .unwrap();
950
951 let mut content2 = content1.clone();
953 let result2 = coordinator
954 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
955 .unwrap();
956
957 assert_eq!(content1, content2);
959 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
960 assert!(result1.converged);
961 assert!(result2.converged);
962 }
963
964 #[test]
965 fn test_apply_fixes_collapses_double_space_without_inline_override() {
966 let mut content = String::from("`<svg>`. Fortunately\n");
969 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
970 FixCoordinator::new()
971 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
972 .unwrap();
973 assert_eq!(
974 content, "`<svg>`. Fortunately\n",
975 "MD064 collapses the sentence double space when not overridden"
976 );
977 }
978
979 #[test]
980 fn test_apply_fixes_honors_inline_configure_file_overrides() {
981 let mut content = String::from(
986 "<!-- rumdl-configure-file { \"MD064\": { \"allow-sentence-double-space\": true } } -->\n\n`<svg>`. Fortunately\n",
987 );
988 let original = content.clone();
989 let rules: Vec<Box<dyn Rule>> = vec![crate::rules::create_rule_by_name("MD064", &Config::default()).unwrap()];
990 FixCoordinator::new()
991 .apply_fixes_iterative(&rules, &[], &mut content, &Config::default(), 10, None)
992 .unwrap();
993 assert_eq!(
994 content, original,
995 "inline rumdl-configure-file override (allow-sentence-double-space) must prevent the MD064 fix"
996 );
997 }
998}