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 inline_config = ctx.inline_config();
266 let filtered_warnings =
267 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
268
269 if filtered_warnings.is_empty() {
270 continue;
271 }
272
273 let has_fixable = filtered_warnings.iter().any(|w| w.fix.is_some());
275 if !has_fixable {
276 continue;
277 }
278
279 match rule.fix(&ctx) {
281 Ok(fixed_content) => {
282 if fixed_content != *content {
283 *content = fixed_content;
284 total_fixed += 1;
285 any_fix_applied = true;
286 this_iter_rule = rule.name().to_string();
287 fixed_rule_names.insert(rule.name().to_string());
288
289 break;
293 }
294 }
295 Err(_) => {
296 continue;
298 }
299 }
300 }
301
302 let current_hash = hash_content(content);
303
304 if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
306 if cycle_start == history.len() - 1 {
307 return Ok(FixResult {
309 rules_fixed: total_fixed,
310 iterations,
311 context_creations: total_ctx_creations,
312 fixed_rule_names,
313 converged: true,
314 conflicting_rules: Vec::new(),
315 conflict_cycle: Vec::new(),
316 });
317 } else {
318 let conflict_cycle: Vec<String> = history[cycle_start + 1..]
321 .iter()
322 .map(|(_, r)| r.clone())
323 .chain(std::iter::once(this_iter_rule.clone()))
324 .filter(|r| !r.is_empty())
325 .collect();
326 let conflicting_rules: Vec<String> = history[cycle_start + 1..]
327 .iter()
328 .map(|(_, r)| r.clone())
329 .chain(std::iter::once(this_iter_rule))
330 .filter(|r| !r.is_empty())
331 .collect::<HashSet<_>>()
332 .into_iter()
333 .collect();
334 return Ok(FixResult {
335 rules_fixed: total_fixed,
336 iterations,
337 context_creations: total_ctx_creations,
338 fixed_rule_names,
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,
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,
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}