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