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