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