1use crate::config::Config;
2use crate::lint_context::LintContext;
3use crate::rule::{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 Self { dependencies }
94 }
95
96 pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
98 let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
100
101 let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
103 for (prereq, dependents) in &self.dependencies {
104 for dependent in dependents {
105 reverse_deps.entry(dependent).or_default().insert(prereq);
106 }
107 }
108
109 let mut sorted = Vec::new();
111 let mut visited: HashSet<&str> = HashSet::new();
112 let mut visiting: HashSet<&str> = HashSet::new();
113
114 fn visit<'a, 'b>(
115 rule_name: &'b str,
116 rule_map: &HashMap<&str, &'a dyn Rule>,
117 reverse_deps: &HashMap<&'b str, HashSet<&'b str>>,
118 visited: &mut HashSet<&'b str>,
119 visiting: &mut HashSet<&'b str>,
120 sorted: &mut Vec<&'a dyn Rule>,
121 ) where
122 'a: 'b,
123 {
124 if visited.contains(rule_name) {
125 return;
126 }
127
128 if visiting.contains(rule_name) {
129 return;
131 }
132
133 visiting.insert(rule_name);
134
135 if let Some(deps) = reverse_deps.get(rule_name) {
137 for dep in deps {
138 if rule_map.contains_key(dep) {
139 visit(dep, rule_map, reverse_deps, visited, visiting, sorted);
140 }
141 }
142 }
143
144 visiting.remove(rule_name);
145 visited.insert(rule_name);
146
147 if let Some(&rule) = rule_map.get(rule_name) {
149 sorted.push(rule);
150 }
151 }
152
153 for rule in rules {
155 visit(
156 rule.name(),
157 &rule_map,
158 &reverse_deps,
159 &mut visited,
160 &mut visiting,
161 &mut sorted,
162 );
163 }
164
165 for rule in rules {
167 if !sorted.iter().any(|r| r.name() == rule.name()) {
168 sorted.push(rule.as_ref());
169 }
170 }
171
172 sorted
173 }
174
175 pub fn apply_fixes_iterative(
183 &self,
184 rules: &[Box<dyn Rule>],
185 _all_warnings: &[LintWarning], content: &mut String,
187 config: &Config,
188 max_iterations: usize,
189 file_path: Option<&std::path::Path>,
190 ) -> Result<FixResult, String> {
191 let max_iterations = max_iterations.min(MAX_ITERATIONS);
193
194 let ordered_rules = self.get_optimal_order(rules);
196
197 let mut total_fixed = 0;
198 let mut total_ctx_creations = 0;
199 let mut iterations = 0;
200
201 let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
204
205 let mut fixed_rule_names: HashSet<&str> = HashSet::new();
207
208 let unfixable_rules: HashSet<String> = config
210 .global
211 .unfixable
212 .iter()
213 .map(|s| crate::config::resolve_rule_name(s))
214 .collect();
215
216 let fixable_rules: HashSet<String> = config
218 .global
219 .fixable
220 .iter()
221 .map(|s| crate::config::resolve_rule_name(s))
222 .collect();
223 let has_fixable_allowlist = !fixable_rules.is_empty();
224
225 while iterations < max_iterations {
227 iterations += 1;
228
229 let flavor = file_path.map_or_else(|| config.markdown_flavor(), |p| config.get_flavor_for_file(p));
232 let ctx = LintContext::new(content, flavor, file_path.map(std::path::Path::to_path_buf));
233 total_ctx_creations += 1;
234
235 let mut any_fix_applied = false;
236 let mut this_iter_rule: &str = "";
238
239 for rule in &ordered_rules {
241 if unfixable_rules.contains(rule.name()) {
243 continue;
244 }
245 if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
246 continue;
247 }
248
249 if rule.should_skip(&ctx) {
251 continue;
252 }
253
254 let Ok(warnings) = rule.check(&ctx) else {
256 continue;
257 };
258
259 if warnings.is_empty() {
260 continue;
261 }
262
263 let inline_config = ctx.inline_config();
265 let filtered_warnings =
266 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
267
268 if filtered_warnings.is_empty() {
269 continue;
270 }
271
272 let has_fixable = filtered_warnings.iter().any(|w| w.fix.is_some());
274 if !has_fixable {
275 continue;
276 }
277
278 match rule.fix(&ctx) {
280 Ok(fixed_content) => {
281 if fixed_content != *content {
282 *content = fixed_content;
283 total_fixed += 1;
284 any_fix_applied = true;
285 this_iter_rule = rule.name();
286 fixed_rule_names.insert(rule.name());
287
288 break;
292 }
293 }
294 Err(_) => {
295 continue;
297 }
298 }
299 }
300
301 let current_hash = hash_content(content);
302
303 if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
305 if cycle_start == history.len() - 1 {
306 return Ok(FixResult {
308 rules_fixed: total_fixed,
309 iterations,
310 context_creations: total_ctx_creations,
311 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
312 converged: true,
313 conflicting_rules: Vec::new(),
314 conflict_cycle: Vec::new(),
315 });
316 } else {
317 let conflict_cycle: Vec<String> = history[cycle_start + 1..]
320 .iter()
321 .map(|(_, r)| r.to_string())
322 .chain(std::iter::once(this_iter_rule.to_string()))
323 .filter(|r| !r.is_empty())
324 .collect();
325 let conflicting_rules: Vec<String> = history[cycle_start + 1..]
326 .iter()
327 .map(|(_, r)| *r)
328 .chain(std::iter::once(this_iter_rule))
329 .filter(|r| !r.is_empty())
330 .collect::<HashSet<&str>>()
331 .into_iter()
332 .map(std::string::ToString::to_string)
333 .collect();
334 return Ok(FixResult {
335 rules_fixed: total_fixed,
336 iterations,
337 context_creations: total_ctx_creations,
338 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
339 converged: false,
340 conflicting_rules,
341 conflict_cycle,
342 });
343 }
344 }
345
346 history.push((current_hash, this_iter_rule));
348
349 if !any_fix_applied {
351 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 }
361 }
362
363 Ok(FixResult {
365 rules_fixed: total_fixed,
366 iterations,
367 context_creations: total_ctx_creations,
368 fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
369 converged: false,
370 conflicting_rules: Vec::new(),
371 conflict_cycle: Vec::new(),
372 })
373 }
374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
380 use std::sync::atomic::{AtomicUsize, Ordering};
381
382 #[derive(Clone)]
384 struct ConditionalFixRule {
385 name: &'static str,
386 check_fn: fn(&str) -> bool,
388 fix_fn: fn(&str) -> String,
390 }
391
392 impl Rule for ConditionalFixRule {
393 fn name(&self) -> &'static str {
394 self.name
395 }
396
397 fn check(&self, ctx: &LintContext) -> LintResult {
398 if (self.check_fn)(ctx.content) {
399 Ok(vec![LintWarning {
400 line: 1,
401 column: 1,
402 end_line: 1,
403 end_column: 1,
404 message: format!("{} issue found", self.name),
405 rule_name: Some(self.name.to_string()),
406 severity: Severity::Error,
407 fix: Some(Fix {
408 range: 0..0,
409 replacement: String::new(),
410 }),
411 }])
412 } else {
413 Ok(vec![])
414 }
415 }
416
417 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
418 Ok((self.fix_fn)(ctx.content))
419 }
420
421 fn description(&self) -> &'static str {
422 "Conditional fix rule for testing"
423 }
424
425 fn category(&self) -> RuleCategory {
426 RuleCategory::Whitespace
427 }
428
429 fn as_any(&self) -> &dyn std::any::Any {
430 self
431 }
432 }
433
434 #[derive(Clone)]
436 struct MockRule {
437 name: &'static str,
438 warnings: Vec<LintWarning>,
439 fix_content: String,
440 }
441
442 impl Rule for MockRule {
443 fn name(&self) -> &'static str {
444 self.name
445 }
446
447 fn check(&self, _ctx: &LintContext) -> LintResult {
448 Ok(self.warnings.clone())
449 }
450
451 fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
452 Ok(self.fix_content.clone())
453 }
454
455 fn description(&self) -> &'static str {
456 "Mock rule for testing"
457 }
458
459 fn category(&self) -> RuleCategory {
460 RuleCategory::Whitespace
461 }
462
463 fn as_any(&self) -> &dyn std::any::Any {
464 self
465 }
466 }
467
468 #[test]
469 fn test_dependency_ordering() {
470 let coordinator = FixCoordinator::new();
471
472 let rules: Vec<Box<dyn Rule>> = vec![
473 Box::new(MockRule {
474 name: "MD009",
475 warnings: vec![],
476 fix_content: "".to_string(),
477 }),
478 Box::new(MockRule {
479 name: "MD013",
480 warnings: vec![],
481 fix_content: "".to_string(),
482 }),
483 Box::new(MockRule {
484 name: "MD010",
485 warnings: vec![],
486 fix_content: "".to_string(),
487 }),
488 Box::new(MockRule {
489 name: "MD007",
490 warnings: vec![],
491 fix_content: "".to_string(),
492 }),
493 ];
494
495 let ordered = coordinator.get_optimal_order(&rules);
496 let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
497
498 let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
500 let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
501 assert!(md010_idx < md007_idx, "MD010 should come before MD007");
502
503 let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
505 let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
506 assert!(md013_idx < md009_idx, "MD013 should come before MD009");
507 }
508
509 #[test]
510 fn test_single_rule_fix() {
511 let coordinator = FixCoordinator::new();
512
513 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
515 name: "RemoveBad",
516 check_fn: |content| content.contains("BAD"),
517 fix_fn: |content| content.replace("BAD", "GOOD"),
518 })];
519
520 let mut content = "This is BAD content".to_string();
521 let config = Config::default();
522
523 let result = coordinator
524 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
525 .unwrap();
526
527 assert_eq!(content, "This is GOOD content");
528 assert_eq!(result.rules_fixed, 1);
529 assert!(result.converged);
530 }
531
532 #[test]
533 fn test_cascading_fixes() {
534 let coordinator = FixCoordinator::new();
538
539 let rules: Vec<Box<dyn Rule>> = vec![
540 Box::new(ConditionalFixRule {
541 name: "Rule1_IndentToFence",
542 check_fn: |content| content.contains("INDENT"),
543 fix_fn: |content| content.replace("INDENT", "FENCE"),
544 }),
545 Box::new(ConditionalFixRule {
546 name: "Rule2_FenceToLang",
547 check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
548 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
549 }),
550 ];
551
552 let mut content = "Code: INDENT".to_string();
553 let config = Config::default();
554
555 let result = coordinator
556 .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
557 .unwrap();
558
559 assert_eq!(content, "Code: FENCE_LANG");
561 assert_eq!(result.rules_fixed, 2);
562 assert!(result.converged);
563 assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
564 }
565
566 #[test]
567 fn test_indirect_cascade() {
568 let coordinator = FixCoordinator::new();
573
574 let rules: Vec<Box<dyn Rule>> = vec![
575 Box::new(ConditionalFixRule {
576 name: "Rule1_AddBlank",
577 check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
578 fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
579 }),
580 Box::new(ConditionalFixRule {
581 name: "Rule2_CodeToFence",
582 check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
584 fix_fn: |content| content.replace("CODE", "FENCE"),
585 }),
586 Box::new(ConditionalFixRule {
587 name: "Rule3_AddLang",
588 check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
589 fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
590 }),
591 ];
592
593 let mut content = "HEADING CODE".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, "HEADING BLANK FENCE_LANG");
602 assert_eq!(result.rules_fixed, 3);
603 assert!(result.converged);
604 }
605
606 #[test]
607 fn test_unfixable_rules_skipped() {
608 let coordinator = FixCoordinator::new();
609
610 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
611 name: "MD001",
612 check_fn: |content| content.contains("BAD"),
613 fix_fn: |content| content.replace("BAD", "GOOD"),
614 })];
615
616 let mut content = "BAD content".to_string();
617 let mut config = Config::default();
618 config.global.unfixable = vec!["MD001".to_string()];
619
620 let result = coordinator
621 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
622 .unwrap();
623
624 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
626 assert!(result.converged);
627 }
628
629 #[test]
630 fn test_fixable_allowlist() {
631 let coordinator = FixCoordinator::new();
632
633 let rules: Vec<Box<dyn Rule>> = vec![
634 Box::new(ConditionalFixRule {
635 name: "MD001",
636 check_fn: |content| content.contains('A'),
637 fix_fn: |content| content.replace('A', "X"),
638 }),
639 Box::new(ConditionalFixRule {
640 name: "MD002",
641 check_fn: |content| content.contains('B'),
642 fix_fn: |content| content.replace('B', "Y"),
643 }),
644 ];
645
646 let mut content = "AB".to_string();
647 let mut config = Config::default();
648 config.global.fixable = vec!["MD001".to_string()];
649
650 let result = coordinator
651 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
652 .unwrap();
653
654 assert_eq!(content, "XB"); assert_eq!(result.rules_fixed, 1);
656 }
657
658 #[test]
659 fn test_unfixable_rules_resolved_from_alias() {
660 let coordinator = FixCoordinator::new();
661
662 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
663 name: "MD001",
664 check_fn: |content| content.contains("BAD"),
665 fix_fn: |content| content.replace("BAD", "GOOD"),
666 })];
667
668 let mut content = "BAD content".to_string();
669 let mut config = Config::default();
670 config.global.unfixable = vec!["heading-increment".to_string()];
672
673 let result = coordinator
674 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
675 .unwrap();
676
677 assert_eq!(content, "BAD content"); assert_eq!(result.rules_fixed, 0);
679 assert!(result.converged);
680 }
681
682 #[test]
683 fn test_fixable_allowlist_resolved_from_alias() {
684 let coordinator = FixCoordinator::new();
685
686 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
687 name: "MD001",
688 check_fn: |content| content.contains("BAD"),
689 fix_fn: |content| content.replace("BAD", "GOOD"),
690 })];
691
692 let mut content = "BAD content".to_string();
693 let mut config = Config::default();
694 config.global.fixable = vec!["heading-increment".to_string()];
696
697 let result = coordinator
698 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
699 .unwrap();
700
701 assert_eq!(content, "GOOD content"); assert_eq!(result.rules_fixed, 1);
703 }
704
705 #[test]
706 fn test_max_iterations_limit() {
707 let coordinator = FixCoordinator::new();
708
709 static COUNTER: AtomicUsize = AtomicUsize::new(0);
711
712 #[derive(Clone)]
713 struct AlwaysChangeRule;
714 impl Rule for AlwaysChangeRule {
715 fn name(&self) -> &'static str {
716 "AlwaysChange"
717 }
718 fn check(&self, _: &LintContext) -> LintResult {
719 Ok(vec![LintWarning {
720 line: 1,
721 column: 1,
722 end_line: 1,
723 end_column: 1,
724 message: "Always".to_string(),
725 rule_name: Some("AlwaysChange".to_string()),
726 severity: Severity::Error,
727 fix: Some(Fix {
728 range: 0..0,
729 replacement: String::new(),
730 }),
731 }])
732 }
733 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
734 COUNTER.fetch_add(1, Ordering::SeqCst);
735 Ok(format!("{}x", ctx.content))
736 }
737 fn description(&self) -> &'static str {
738 "Always changes"
739 }
740 fn category(&self) -> RuleCategory {
741 RuleCategory::Whitespace
742 }
743 fn as_any(&self) -> &dyn std::any::Any {
744 self
745 }
746 }
747
748 COUNTER.store(0, Ordering::SeqCst);
749 let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
750
751 let mut content = "test".to_string();
752 let config = Config::default();
753
754 let result = coordinator
755 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
756 .unwrap();
757
758 assert_eq!(result.iterations, 5);
760 assert!(!result.converged);
761 assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
762 }
763
764 #[test]
765 fn test_empty_rules() {
766 let coordinator = FixCoordinator::new();
767 let rules: Vec<Box<dyn Rule>> = vec![];
768
769 let mut content = "unchanged".to_string();
770 let config = Config::default();
771
772 let result = coordinator
773 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
774 .unwrap();
775
776 assert_eq!(result.rules_fixed, 0);
777 assert_eq!(result.iterations, 1);
778 assert!(result.converged);
779 assert_eq!(content, "unchanged");
780 }
781
782 #[test]
783 fn test_no_warnings_no_changes() {
784 let coordinator = FixCoordinator::new();
785
786 let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
788 name: "NoIssues",
789 check_fn: |_| false, fix_fn: |content| content.to_string(),
791 })];
792
793 let mut content = "clean content".to_string();
794 let config = Config::default();
795
796 let result = coordinator
797 .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
798 .unwrap();
799
800 assert_eq!(content, "clean content");
801 assert_eq!(result.rules_fixed, 0);
802 assert!(result.converged);
803 }
804
805 #[test]
806 fn test_oscillation_detection() {
807 let coordinator = FixCoordinator::new();
811
812 let rules: Vec<Box<dyn Rule>> = vec![
813 Box::new(ConditionalFixRule {
814 name: "RuleA",
815 check_fn: |content| content.contains("foo"),
816 fix_fn: |content| content.replace("foo", "bar"),
817 }),
818 Box::new(ConditionalFixRule {
819 name: "RuleB",
820 check_fn: |content| content.contains("bar"),
821 fix_fn: |content| content.replace("bar", "foo"),
822 }),
823 ];
824
825 let mut content = "foo".to_string();
826 let config = Config::default();
827
828 let result = coordinator
829 .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
830 .unwrap();
831
832 assert!(!result.converged, "Should not converge in an oscillating pair");
834 assert!(
835 result.iterations < 10,
836 "Cycle detection should stop well before max_iterations (got {})",
837 result.iterations
838 );
839
840 let mut conflicting = result.conflicting_rules.clone();
842 conflicting.sort();
843 assert_eq!(
844 conflicting,
845 vec!["RuleA".to_string(), "RuleB".to_string()],
846 "Both oscillating rules must be reported"
847 );
848 assert_eq!(
849 result.conflict_cycle,
850 vec!["RuleA".to_string(), "RuleB".to_string()],
851 "Cycle should preserve the observed application order"
852 );
853 }
854
855 #[test]
856 fn test_cyclic_dependencies_handled() {
857 let mut coordinator = FixCoordinator::new();
858
859 coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
861 coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
862 coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
863
864 let rules: Vec<Box<dyn Rule>> = vec![
865 Box::new(MockRule {
866 name: "RuleA",
867 warnings: vec![],
868 fix_content: "".to_string(),
869 }),
870 Box::new(MockRule {
871 name: "RuleB",
872 warnings: vec![],
873 fix_content: "".to_string(),
874 }),
875 Box::new(MockRule {
876 name: "RuleC",
877 warnings: vec![],
878 fix_content: "".to_string(),
879 }),
880 ];
881
882 let ordered = coordinator.get_optimal_order(&rules);
884
885 assert_eq!(ordered.len(), 3);
887 }
888
889 #[test]
890 fn test_fix_is_idempotent() {
891 let coordinator = FixCoordinator::new();
893
894 let rules: Vec<Box<dyn Rule>> = vec![
895 Box::new(ConditionalFixRule {
896 name: "Rule1",
897 check_fn: |content| content.contains('A'),
898 fix_fn: |content| content.replace('A', "B"),
899 }),
900 Box::new(ConditionalFixRule {
901 name: "Rule2",
902 check_fn: |content| content.contains('B') && !content.contains('C'),
903 fix_fn: |content| content.replace('B', "BC"),
904 }),
905 ];
906
907 let config = Config::default();
908
909 let mut content1 = "A".to_string();
911 let result1 = coordinator
912 .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
913 .unwrap();
914
915 let mut content2 = content1.clone();
917 let result2 = coordinator
918 .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
919 .unwrap();
920
921 assert_eq!(content1, content2);
923 assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
924 assert!(result1.converged);
925 assert!(result2.converged);
926 }
927}