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 mut any_fix_applied = false;
232 let mut this_iter_rule: &str = "";
234
235 for rule in &ordered_rules {
237 if unfixable_rules.contains(rule.name()) {
239 continue;
240 }
241 if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
242 continue;
243 }
244
245 if rule.should_skip(&ctx) {
247 continue;
248 }
249
250 let Ok(warnings) = rule.check(&ctx) else {
252 continue;
253 };
254
255 if warnings.is_empty() {
256 continue;
257 }
258
259 let inline_config = ctx.inline_config();
261 let filtered_warnings =
262 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
263
264 if filtered_warnings.is_empty() {
265 continue;
266 }
267
268 let has_inline_fix = filtered_warnings.iter().any(|w| w.fix.is_some());
278 let rule_advertises_fix = rule.fix_capability() != FixCapability::Unfixable;
279 if !has_inline_fix && !rule_advertises_fix {
280 continue;
281 }
282
283 match rule.fix(&ctx) {
285 Ok(fixed_content) => {
286 if fixed_content != *content {
287 *content = fixed_content;
288 total_fixed += 1;
289 any_fix_applied = true;
290 this_iter_rule = rule.name();
291 fixed_rule_names.insert(rule.name());
292
293 break;
297 }
298 }
299 Err(_) => {
300 continue;
302 }
303 }
304 }
305
306 let current_hash = hash_content(content);
307
308 if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
310 if cycle_start == history.len() - 1 {
311 return Ok(FixResult {
313 rules_fixed: total_fixed,
314 iterations,
315 context_creations: total_ctx_creations,
316 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
317 converged: true,
318 conflicting_rules: Vec::new(),
319 conflict_cycle: Vec::new(),
320 });
321 } else {
322 let conflict_cycle: Vec<String> = history[cycle_start + 1..]
325 .iter()
326 .map(|(_, r)| r.to_string())
327 .chain(std::iter::once(this_iter_rule.to_string()))
328 .filter(|r| !r.is_empty())
329 .collect();
330 let conflicting_rules: Vec<String> = history[cycle_start + 1..]
331 .iter()
332 .map(|(_, r)| *r)
333 .chain(std::iter::once(this_iter_rule))
334 .filter(|r| !r.is_empty())
335 .collect::<HashSet<&str>>()
336 .into_iter()
337 .map(std::string::ToString::to_string)
338 .collect();
339 return Ok(FixResult {
340 rules_fixed: total_fixed,
341 iterations,
342 context_creations: total_ctx_creations,
343 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
344 converged: false,
345 conflicting_rules,
346 conflict_cycle,
347 });
348 }
349 }
350
351 history.push((current_hash, this_iter_rule));
353
354 if !any_fix_applied {
356 return Ok(FixResult {
357 rules_fixed: total_fixed,
358 iterations,
359 context_creations: total_ctx_creations,
360 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
361 converged: true,
362 conflicting_rules: Vec::new(),
363 conflict_cycle: Vec::new(),
364 });
365 }
366 }
367
368 Ok(FixResult {
370 rules_fixed: total_fixed,
371 iterations,
372 context_creations: total_ctx_creations,
373 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
374 converged: false,
375 conflicting_rules: Vec::new(),
376 conflict_cycle: Vec::new(),
377 })
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
385 use std::sync::atomic::{AtomicUsize, Ordering};
386
387 #[derive(Clone)]
389 struct ConditionalFixRule {
390 name: &'static str,
391 check_fn: fn(&str) -> bool,
393 fix_fn: fn(&str) -> String,
395 }
396
397 impl Rule for ConditionalFixRule {
398 fn name(&self) -> &'static str {
399 self.name
400 }
401
402 fn check(&self, ctx: &LintContext) -> LintResult {
403 if (self.check_fn)(ctx.content) {
404 Ok(vec![LintWarning {
405 line: 1,
406 column: 1,
407 end_line: 1,
408 end_column: 1,
409 message: format!("{} issue found", self.name),
410 rule_name: Some(self.name.to_string()),
411 severity: Severity::Error,
412 fix: Some(Fix::new(0..0, String::new())),
413 }])
414 } else {
415 Ok(vec![])
416 }
417 }
418
419 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
420 Ok((self.fix_fn)(ctx.content))
421 }
422
423 fn description(&self) -> &'static str {
424 "Conditional fix rule for testing"
425 }
426
427 fn category(&self) -> RuleCategory {
428 RuleCategory::Whitespace
429 }
430
431 fn as_any(&self) -> &dyn std::any::Any {
432 self
433 }
434 }
435
436 #[derive(Clone)]
438 struct MockRule {
439 name: &'static str,
440 warnings: Vec<LintWarning>,
441 fix_content: String,
442 }
443
444 impl Rule for MockRule {
445 fn name(&self) -> &'static str {
446 self.name
447 }
448
449 fn check(&self, _ctx: &LintContext) -> LintResult {
450 Ok(self.warnings.clone())
451 }
452
453 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
454 Ok(self.fix_content.clone())
455 }
456
457 fn description(&self) -> &'static str {
458 "Mock rule for testing"
459 }
460
461 fn category(&self) -> RuleCategory {
462 RuleCategory::Whitespace
463 }
464
465 fn as_any(&self) -> &dyn std::any::Any {
466 self
467 }
468 }
469
470 #[test]
471 fn test_dependency_ordering() {
472 let coordinator = FixCoordinator::new();
473
474 let rules: Vec<Box<dyn Rule>> = vec![
475 Box::new(MockRule {
476 name: "MD009",
477 warnings: vec![],
478 fix_content: "".to_string(),
479 }),
480 Box::new(MockRule {
481 name: "MD013",
482 warnings: vec![],
483 fix_content: "".to_string(),
484 }),
485 Box::new(MockRule {
486 name: "MD010",
487 warnings: vec![],
488 fix_content: "".to_string(),
489 }),
490 Box::new(MockRule {
491 name: "MD007",
492 warnings: vec![],
493 fix_content: "".to_string(),
494 }),
495 ];
496
497 let ordered = coordinator.get_optimal_order(&rules);
498 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
499
500 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
502 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
503 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
504
505 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
507 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
508 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
509 }
510
511 #[test]
512 fn test_single_rule_fix() {
513 let coordinator = FixCoordinator::new();
514
515 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
517 name: "RemoveBad",
518 check_fn: |content| content.contains("BAD"),
519 fix_fn: |content| content.replace("BAD", "GOOD"),
520 })];
521
522 let mut content = "This is BAD content".to_string();
523 let config = Config::default();
524
525 let result = coordinator
526 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
527 .unwrap();
528
529 assert_eq!(content, "This is GOOD content");
530 assert_eq!(result.rules_fixed, 1);
531 assert!(result.converged);
532 }
533
534 #[test]
535 fn test_cascading_fixes() {
536 let coordinator = FixCoordinator::new();
540
541 let rules: Vec<Box<dyn Rule>> = vec![
542 Box::new(ConditionalFixRule {
543 name: "Rule1_IndentToFence",
544 check_fn: |content| content.contains("INDENT"),
545 fix_fn: |content| content.replace("INDENT", "FENCE"),
546 }),
547 Box::new(ConditionalFixRule {
548 name: "Rule2_FenceToLang",
549 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
550 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
551 }),
552 ];
553
554 let mut content = "Code: INDENT".to_string();
555 let config = Config::default();
556
557 let result = coordinator
558 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
559 .unwrap();
560
561 assert_eq!(content, "Code: FENCE_LANG");
563 assert_eq!(result.rules_fixed, 2);
564 assert!(result.converged);
565 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
566 }
567
568 #[test]
569 fn test_indirect_cascade() {
570 let coordinator = FixCoordinator::new();
575
576 let rules: Vec<Box<dyn Rule>> = vec![
577 Box::new(ConditionalFixRule {
578 name: "Rule1_AddBlank",
579 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
580 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
581 }),
582 Box::new(ConditionalFixRule {
583 name: "Rule2_CodeToFence",
584 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
586 fix_fn: |content| content.replace("CODE", "FENCE"),
587 }),
588 Box::new(ConditionalFixRule {
589 name: "Rule3_AddLang",
590 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
591 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
592 }),
593 ];
594
595 let mut content = "HEADING CODE".to_string();
596 let config = Config::default();
597
598 let result = coordinator
599 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
600 .unwrap();
601
602 assert_eq!(content, "HEADING BLANK FENCE_LANG");
604 assert_eq!(result.rules_fixed, 3);
605 assert!(result.converged);
606 }
607
608 #[test]
609 fn test_unfixable_rules_skipped() {
610 let coordinator = FixCoordinator::new();
611
612 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
613 name: "MD001",
614 check_fn: |content| content.contains("BAD"),
615 fix_fn: |content| content.replace("BAD", "GOOD"),
616 })];
617
618 let mut content = "BAD content".to_string();
619 let mut config = Config::default();
620 config.global.unfixable = vec!["MD001".to_string()];
621
622 let result = coordinator
623 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
624 .unwrap();
625
626 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
628 assert!(result.converged);
629 }
630
631 #[test]
632 fn test_fixable_allowlist() {
633 let coordinator = FixCoordinator::new();
634
635 let rules: Vec<Box<dyn Rule>> = vec![
636 Box::new(ConditionalFixRule {
637 name: "MD001",
638 check_fn: |content| content.contains('A'),
639 fix_fn: |content| content.replace('A', "X"),
640 }),
641 Box::new(ConditionalFixRule {
642 name: "MD002",
643 check_fn: |content| content.contains('B'),
644 fix_fn: |content| content.replace('B', "Y"),
645 }),
646 ];
647
648 let mut content = "AB".to_string();
649 let mut config = Config::default();
650 config.global.fixable = vec!["MD001".to_string()];
651
652 let result = coordinator
653 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
654 .unwrap();
655
656 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
658 }
659
660 #[test]
667 fn test_unfixable_rules_resolved_from_alias() {
668 let coordinator = FixCoordinator::new();
669
670 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
671 name: "MD001",
672 check_fn: |content| content.contains("BAD"),
673 fix_fn: |content| content.replace("BAD", "GOOD"),
674 })];
675
676 let mut content = "BAD content".to_string();
677 let mut config = Config::default();
678 config.global.unfixable = vec!["heading-increment".to_string()];
680 config.canonicalize_rule_lists();
682
683 let result = coordinator
684 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
685 .unwrap();
686
687 assert_eq!(content, "BAD content");
688 assert_eq!(result.rules_fixed, 0);
689 assert!(result.converged);
690 }
691
692 #[test]
696 fn test_fixable_allowlist_resolved_from_alias() {
697 let coordinator = FixCoordinator::new();
698
699 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
700 name: "MD001",
701 check_fn: |content| content.contains("BAD"),
702 fix_fn: |content| content.replace("BAD", "GOOD"),
703 })];
704
705 let mut content = "BAD content".to_string();
706 let mut config = Config::default();
707 config.global.fixable = vec!["heading-increment".to_string()];
708 config.canonicalize_rule_lists();
709
710 let result = coordinator
711 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
712 .unwrap();
713
714 assert_eq!(content, "GOOD content");
715 assert_eq!(result.rules_fixed, 1);
716 }
717
718 #[test]
719 fn test_max_iterations_limit() {
720 let coordinator = FixCoordinator::new();
721
722 static COUNTER: AtomicUsize = AtomicUsize::new(0);
724
725 #[derive(Clone)]
726 struct AlwaysChangeRule;
727 impl Rule for AlwaysChangeRule {
728 fn name(&self) -> &'static str {
729 "AlwaysChange"
730 }
731 fn check(&self, _: &LintContext) -> LintResult {
732 Ok(vec![LintWarning {
733 line: 1,
734 column: 1,
735 end_line: 1,
736 end_column: 1,
737 message: "Always".to_string(),
738 rule_name: Some("AlwaysChange".to_string()),
739 severity: Severity::Error,
740 fix: Some(Fix::new(0..0, String::new())),
741 }])
742 }
743 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
744 COUNTER.fetch_add(1, Ordering::SeqCst);
745 Ok(format!("{}x", ctx.content))
746 }
747 fn description(&self) -> &'static str {
748 "Always changes"
749 }
750 fn category(&self) -> RuleCategory {
751 RuleCategory::Whitespace
752 }
753 fn as_any(&self) -> &dyn std::any::Any {
754 self
755 }
756 }
757
758 COUNTER.store(0, Ordering::SeqCst);
759 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
760
761 let mut content = "test".to_string();
762 let config = Config::default();
763
764 let result = coordinator
765 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
766 .unwrap();
767
768 assert_eq!(result.iterations, 5);
770 assert!(!result.converged);
771 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
772 }
773
774 #[test]
775 fn test_empty_rules() {
776 let coordinator = FixCoordinator::new();
777 let rules: Vec<Box<dyn Rule>> = vec![];
778
779 let mut content = "unchanged".to_string();
780 let config = Config::default();
781
782 let result = coordinator
783 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
784 .unwrap();
785
786 assert_eq!(result.rules_fixed, 0);
787 assert_eq!(result.iterations, 1);
788 assert!(result.converged);
789 assert_eq!(content, "unchanged");
790 }
791
792 #[test]
793 fn test_no_warnings_no_changes() {
794 let coordinator = FixCoordinator::new();
795
796 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
798 name: "NoIssues",
799 check_fn: |_| false, fix_fn: |content| content.to_string(),
801 })];
802
803 let mut content = "clean content".to_string();
804 let config = Config::default();
805
806 let result = coordinator
807 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
808 .unwrap();
809
810 assert_eq!(content, "clean content");
811 assert_eq!(result.rules_fixed, 0);
812 assert!(result.converged);
813 }
814
815 #[test]
816 fn test_oscillation_detection() {
817 let coordinator = FixCoordinator::new();
821
822 let rules: Vec<Box<dyn Rule>> = vec![
823 Box::new(ConditionalFixRule {
824 name: "RuleA",
825 check_fn: |content| content.contains("foo"),
826 fix_fn: |content| content.replace("foo", "bar"),
827 }),
828 Box::new(ConditionalFixRule {
829 name: "RuleB",
830 check_fn: |content| content.contains("bar"),
831 fix_fn: |content| content.replace("bar", "foo"),
832 }),
833 ];
834
835 let mut content = "foo".to_string();
836 let config = Config::default();
837
838 let result = coordinator
839 .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
840 .unwrap();
841
842 assert!(!result.converged, "Should not converge in an oscillating pair");
844 assert!(
845 result.iterations < 10,
846 "Cycle detection should stop well before max_iterations (got {})",
847 result.iterations
848 );
849
850 let mut conflicting = result.conflicting_rules.clone();
852 conflicting.sort();
853 assert_eq!(
854 conflicting,
855 vec!["RuleA".to_string(), "RuleB".to_string()],
856 "Both oscillating rules must be reported"
857 );
858 assert_eq!(
859 result.conflict_cycle,
860 vec!["RuleA".to_string(), "RuleB".to_string()],
861 "Cycle should preserve the observed application order"
862 );
863 }
864
865 #[test]
866 fn test_cyclic_dependencies_handled() {
867 let mut coordinator = FixCoordinator::new();
868
869 coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
871 coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
872 coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
873
874 let rules: Vec<Box<dyn Rule>> = vec![
875 Box::new(MockRule {
876 name: "RuleA",
877 warnings: vec![],
878 fix_content: "".to_string(),
879 }),
880 Box::new(MockRule {
881 name: "RuleB",
882 warnings: vec![],
883 fix_content: "".to_string(),
884 }),
885 Box::new(MockRule {
886 name: "RuleC",
887 warnings: vec![],
888 fix_content: "".to_string(),
889 }),
890 ];
891
892 let ordered = coordinator.get_optimal_order(&rules);
894
895 assert_eq!(ordered.len(), 3);
897 }
898
899 #[test]
900 fn test_fix_is_idempotent() {
901 let coordinator = FixCoordinator::new();
903
904 let rules: Vec<Box<dyn Rule>> = vec![
905 Box::new(ConditionalFixRule {
906 name: "Rule1",
907 check_fn: |content| content.contains('A'),
908 fix_fn: |content| content.replace('A', "B"),
909 }),
910 Box::new(ConditionalFixRule {
911 name: "Rule2",
912 check_fn: |content| content.contains('B') && !content.contains('C'),
913 fix_fn: |content| content.replace('B', "BC"),
914 }),
915 ];
916
917 let config = Config::default();
918
919 let mut content1 = "A".to_string();
921 let result1 = coordinator
922 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
923 .unwrap();
924
925 let mut content2 = content1.clone();
927 let result2 = coordinator
928 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
929 .unwrap();
930
931 assert_eq!(content1, content2);
933 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
934 assert!(result1.converged);
935 assert!(result2.converged);
936 }
937}