rumdl_lib/
fix_coordinator.rs

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
8/// Maximum number of fix iterations before stopping (same as Ruff)
9const MAX_ITERATIONS: usize = 100;
10
11/// Result of applying fixes iteratively
12///
13/// This struct provides named fields instead of a tuple to prevent
14/// confusion about the meaning of each value.
15#[derive(Debug, Clone)]
16pub struct FixResult {
17    /// Total number of rules that successfully applied fixes
18    pub rules_fixed: usize,
19    /// Number of fix iterations performed
20    pub iterations: usize,
21    /// Number of LintContext instances created during fixing
22    pub context_creations: usize,
23    /// Names of rules that applied fixes
24    pub fixed_rule_names: HashSet<String>,
25    /// Whether the fix process converged (content stabilized)
26    pub converged: bool,
27}
28
29/// Calculate hash of content for convergence detection
30fn hash_content(content: &str) -> u64 {
31    let mut hasher = DefaultHasher::new();
32    content.hash(&mut hasher);
33    hasher.finish()
34}
35
36/// Coordinates rule fixing to minimize the number of passes needed
37pub struct FixCoordinator {
38    /// Rules that should run before others (rule -> rules that depend on it)
39    dependencies: HashMap<&'static str, Vec<&'static str>>,
40}
41
42impl Default for FixCoordinator {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl FixCoordinator {
49    pub fn new() -> Self {
50        let mut dependencies = HashMap::new();
51
52        // CRITICAL DEPENDENCIES:
53        // These dependencies prevent cascading issues that require multiple passes
54
55        // MD064 (multiple consecutive spaces) MUST run before:
56        // - MD010 (tabs->spaces) - MD010 replaces tabs with multiple spaces (e.g., 4),
57        //   which MD064 would incorrectly collapse back to 1 space if it ran after
58        dependencies.insert("MD064", vec!["MD010"]);
59
60        // MD010 (tabs->spaces) MUST run before:
61        // - MD007 (list indentation) - because tabs affect indent calculation
62        // - MD005 (list indent consistency) - same reason
63        dependencies.insert("MD010", vec!["MD007", "MD005"]);
64
65        // MD013 (line length) MUST run before:
66        // - MD009 (trailing spaces) - line wrapping might add trailing spaces that need cleanup
67        // - MD012 (multiple blanks) - reflowing can affect blank lines
68        // Note: MD013 now trims trailing whitespace during reflow to prevent mid-line spaces
69        dependencies.insert("MD013", vec!["MD009", "MD012"]);
70
71        // MD004 (list style) should run before:
72        // - MD007 (list indentation) - changing markers affects indentation
73        dependencies.insert("MD004", vec!["MD007"]);
74
75        // MD022/MD023 (heading spacing) should run before:
76        // - MD012 (multiple blanks) - heading fixes can affect blank lines
77        dependencies.insert("MD022", vec!["MD012"]);
78        dependencies.insert("MD023", vec!["MD012"]);
79
80        // MD070 (nested fence collision) MUST run before:
81        // - MD040 (code language) - MD070 changes block structure, making orphan fences into content
82        // - MD031 (blanks around fences) - same reason
83        dependencies.insert("MD070", vec!["MD040", "MD031"]);
84
85        Self { dependencies }
86    }
87
88    /// Get the optimal order for running rules based on dependencies
89    pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
90        // Build a map of rule names to rules for quick lookup
91        let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
92
93        // Build reverse dependencies (rule -> rules it depends on)
94        let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
95        for (prereq, dependents) in &self.dependencies {
96            for dependent in dependents {
97                reverse_deps.entry(dependent).or_default().insert(prereq);
98            }
99        }
100
101        // Perform topological sort
102        let mut sorted = Vec::new();
103        let mut visited = HashSet::new();
104        let mut visiting = HashSet::new();
105
106        fn visit<'a>(
107            rule_name: &str,
108            rule_map: &HashMap<&str, &'a dyn Rule>,
109            reverse_deps: &HashMap<&str, HashSet<&str>>,
110            visited: &mut HashSet<String>,
111            visiting: &mut HashSet<String>,
112            sorted: &mut Vec<&'a dyn Rule>,
113        ) {
114            if visited.contains(rule_name) {
115                return;
116            }
117
118            if visiting.contains(rule_name) {
119                // Cycle detected, but we'll just skip it
120                return;
121            }
122
123            visiting.insert(rule_name.to_string());
124
125            // Visit dependencies first
126            if let Some(deps) = reverse_deps.get(rule_name) {
127                for dep in deps {
128                    if rule_map.contains_key(dep) {
129                        visit(dep, rule_map, reverse_deps, visited, visiting, sorted);
130                    }
131                }
132            }
133
134            visiting.remove(rule_name);
135            visited.insert(rule_name.to_string());
136
137            // Add this rule to sorted list
138            if let Some(&rule) = rule_map.get(rule_name) {
139                sorted.push(rule);
140            }
141        }
142
143        // Visit all rules
144        for rule in rules {
145            visit(
146                rule.name(),
147                &rule_map,
148                &reverse_deps,
149                &mut visited,
150                &mut visiting,
151                &mut sorted,
152            );
153        }
154
155        // Add any rules not in dependency graph
156        for rule in rules {
157            if !sorted.iter().any(|r| r.name() == rule.name()) {
158                sorted.push(rule.as_ref());
159            }
160        }
161
162        sorted
163    }
164
165    /// Apply fixes iteratively until no more fixes are needed or max iterations reached.
166    ///
167    /// This implements a Ruff-inspired fix loop that re-checks ALL rules after each fix
168    /// to detect cascading issues (e.g., MD046 creating code blocks that MD040 needs to fix).
169    pub fn apply_fixes_iterative(
170        &self,
171        rules: &[Box<dyn Rule>],
172        _all_warnings: &[LintWarning], // Kept for API compatibility, but we re-check all rules
173        content: &mut String,
174        config: &Config,
175        max_iterations: usize,
176    ) -> Result<FixResult, String> {
177        // Use the minimum of max_iterations parameter and MAX_ITERATIONS constant
178        let max_iterations = max_iterations.min(MAX_ITERATIONS);
179
180        // Get optimal rule order based on dependencies
181        let ordered_rules = self.get_optimal_order(rules);
182
183        let mut total_fixed = 0;
184        let mut total_ctx_creations = 0;
185        let mut iterations = 0;
186        let mut previous_hash = hash_content(content);
187
188        // Track which rules actually applied fixes
189        let mut fixed_rule_names = HashSet::new();
190
191        // Build set of unfixable rules for quick lookup
192        let unfixable_rules: HashSet<&str> = config.global.unfixable.iter().map(|s| s.as_str()).collect();
193
194        // Build set of fixable rules (if specified)
195        let fixable_rules: HashSet<&str> = config.global.fixable.iter().map(|s| s.as_str()).collect();
196        let has_fixable_allowlist = !fixable_rules.is_empty();
197
198        // Ruff-style fix loop: keep applying fixes until content stabilizes
199        while iterations < max_iterations {
200            iterations += 1;
201
202            // Create fresh context for this iteration
203            let ctx = LintContext::new(content, config.markdown_flavor(), None);
204            total_ctx_creations += 1;
205
206            let mut any_fix_applied = false;
207
208            // Check and fix each rule in dependency order
209            for rule in &ordered_rules {
210                // Skip disabled rules
211                if unfixable_rules.contains(rule.name()) {
212                    continue;
213                }
214                if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
215                    continue;
216                }
217
218                // Check if this rule has any current warnings
219                let warnings = match rule.check(&ctx) {
220                    Ok(w) => w,
221                    Err(_) => continue,
222                };
223
224                if warnings.is_empty() {
225                    continue;
226                }
227
228                // Check if any warnings are fixable
229                let has_fixable = warnings.iter().any(|w| w.fix.is_some());
230                if !has_fixable {
231                    continue;
232                }
233
234                // Apply fix
235                match rule.fix(&ctx) {
236                    Ok(fixed_content) => {
237                        if fixed_content != *content {
238                            *content = fixed_content;
239                            total_fixed += 1;
240                            any_fix_applied = true;
241                            fixed_rule_names.insert(rule.name().to_string());
242
243                            // Break to re-check all rules with the new content
244                            // This is the key difference from the old approach:
245                            // we always restart from the beginning after a fix
246                            break;
247                        }
248                    }
249                    Err(_) => {
250                        // Error applying fix, continue to next rule
251                        continue;
252                    }
253                }
254            }
255
256            // Check if content has stabilized (hash-based convergence)
257            let current_hash = hash_content(content);
258            if current_hash == previous_hash {
259                // Content unchanged - converged!
260                return Ok(FixResult {
261                    rules_fixed: total_fixed,
262                    iterations,
263                    context_creations: total_ctx_creations,
264                    fixed_rule_names,
265                    converged: true,
266                });
267            }
268            previous_hash = current_hash;
269
270            // If no fixes were applied this iteration, we've converged
271            if !any_fix_applied {
272                return Ok(FixResult {
273                    rules_fixed: total_fixed,
274                    iterations,
275                    context_creations: total_ctx_creations,
276                    fixed_rule_names,
277                    converged: true,
278                });
279            }
280        }
281
282        // Hit max iterations - did not converge
283        Ok(FixResult {
284            rules_fixed: total_fixed,
285            iterations,
286            context_creations: total_ctx_creations,
287            fixed_rule_names,
288            converged: false,
289        })
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::config::GlobalConfig;
297    use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
298    use std::sync::atomic::{AtomicUsize, Ordering};
299
300    /// Mock rule that checks content and applies fixes based on a condition
301    #[derive(Clone)]
302    struct ConditionalFixRule {
303        name: &'static str,
304        /// Function to check if content has issues
305        check_fn: fn(&str) -> bool,
306        /// Function to fix content
307        fix_fn: fn(&str) -> String,
308    }
309
310    impl Rule for ConditionalFixRule {
311        fn name(&self) -> &'static str {
312            self.name
313        }
314
315        fn check(&self, ctx: &LintContext) -> LintResult {
316            if (self.check_fn)(ctx.content) {
317                Ok(vec![LintWarning {
318                    line: 1,
319                    column: 1,
320                    end_line: 1,
321                    end_column: 1,
322                    message: format!("{} issue found", self.name),
323                    rule_name: Some(self.name.to_string()),
324                    severity: Severity::Error,
325                    fix: Some(Fix {
326                        range: 0..0,
327                        replacement: String::new(),
328                    }),
329                }])
330            } else {
331                Ok(vec![])
332            }
333        }
334
335        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
336            Ok((self.fix_fn)(ctx.content))
337        }
338
339        fn description(&self) -> &'static str {
340            "Conditional fix rule for testing"
341        }
342
343        fn category(&self) -> RuleCategory {
344            RuleCategory::Whitespace
345        }
346
347        fn as_any(&self) -> &dyn std::any::Any {
348            self
349        }
350    }
351
352    // Simple mock rule for basic tests
353    #[derive(Clone)]
354    struct MockRule {
355        name: &'static str,
356        warnings: Vec<LintWarning>,
357        fix_content: String,
358    }
359
360    impl Rule for MockRule {
361        fn name(&self) -> &'static str {
362            self.name
363        }
364
365        fn check(&self, _ctx: &LintContext) -> LintResult {
366            Ok(self.warnings.clone())
367        }
368
369        fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
370            Ok(self.fix_content.clone())
371        }
372
373        fn description(&self) -> &'static str {
374            "Mock rule for testing"
375        }
376
377        fn category(&self) -> RuleCategory {
378            RuleCategory::Whitespace
379        }
380
381        fn as_any(&self) -> &dyn std::any::Any {
382            self
383        }
384    }
385
386    #[test]
387    fn test_dependency_ordering() {
388        let coordinator = FixCoordinator::new();
389
390        let rules: Vec<Box<dyn Rule>> = vec![
391            Box::new(MockRule {
392                name: "MD009",
393                warnings: vec![],
394                fix_content: "".to_string(),
395            }),
396            Box::new(MockRule {
397                name: "MD013",
398                warnings: vec![],
399                fix_content: "".to_string(),
400            }),
401            Box::new(MockRule {
402                name: "MD010",
403                warnings: vec![],
404                fix_content: "".to_string(),
405            }),
406            Box::new(MockRule {
407                name: "MD007",
408                warnings: vec![],
409                fix_content: "".to_string(),
410            }),
411        ];
412
413        let ordered = coordinator.get_optimal_order(&rules);
414        let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
415
416        // MD010 should come before MD007 (dependency)
417        let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
418        let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
419        assert!(md010_idx < md007_idx, "MD010 should come before MD007");
420
421        // MD013 should come before MD009 (dependency)
422        let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
423        let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
424        assert!(md013_idx < md009_idx, "MD013 should come before MD009");
425    }
426
427    #[test]
428    fn test_single_rule_fix() {
429        let coordinator = FixCoordinator::new();
430
431        // Rule that removes "BAD" from content
432        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
433            name: "RemoveBad",
434            check_fn: |content| content.contains("BAD"),
435            fix_fn: |content| content.replace("BAD", "GOOD"),
436        })];
437
438        let mut content = "This is BAD content".to_string();
439        let config = Config {
440            global: GlobalConfig::default(),
441            per_file_ignores: HashMap::new(),
442            rules: Default::default(),
443            project_root: None,
444        };
445
446        let result = coordinator
447            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
448            .unwrap();
449
450        assert_eq!(content, "This is GOOD content");
451        assert_eq!(result.rules_fixed, 1);
452        assert!(result.converged);
453    }
454
455    #[test]
456    fn test_cascading_fixes() {
457        // Simulates MD046 -> MD040 cascade:
458        // Rule1: converts "INDENT" to "FENCE" (like MD046 converting indented to fenced)
459        // Rule2: converts "FENCE" to "FENCE_LANG" (like MD040 adding language)
460        let coordinator = FixCoordinator::new();
461
462        let rules: Vec<Box<dyn Rule>> = vec![
463            Box::new(ConditionalFixRule {
464                name: "Rule1_IndentToFence",
465                check_fn: |content| content.contains("INDENT"),
466                fix_fn: |content| content.replace("INDENT", "FENCE"),
467            }),
468            Box::new(ConditionalFixRule {
469                name: "Rule2_FenceToLang",
470                check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
471                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
472            }),
473        ];
474
475        let mut content = "Code: INDENT".to_string();
476        let config = Config {
477            global: GlobalConfig::default(),
478            per_file_ignores: HashMap::new(),
479            rules: Default::default(),
480            project_root: None,
481        };
482
483        let result = coordinator
484            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10)
485            .unwrap();
486
487        // Should reach final state in one run (internally multiple iterations)
488        assert_eq!(content, "Code: FENCE_LANG");
489        assert_eq!(result.rules_fixed, 2);
490        assert!(result.converged);
491        assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
492    }
493
494    #[test]
495    fn test_indirect_cascade() {
496        // Simulates MD022 -> MD046 -> MD040 indirect cascade:
497        // Rule1: adds "BLANK" (like MD022 adding blank line)
498        // Rule2: only triggers if "BLANK" present, converts "CODE" to "FENCE"
499        // Rule3: converts "FENCE" to "FENCE_LANG"
500        let coordinator = FixCoordinator::new();
501
502        let rules: Vec<Box<dyn Rule>> = vec![
503            Box::new(ConditionalFixRule {
504                name: "Rule1_AddBlank",
505                check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
506                fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
507            }),
508            Box::new(ConditionalFixRule {
509                name: "Rule2_CodeToFence",
510                // Only detects CODE as issue if BLANK is present (simulates CommonMark rule)
511                check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
512                fix_fn: |content| content.replace("CODE", "FENCE"),
513            }),
514            Box::new(ConditionalFixRule {
515                name: "Rule3_AddLang",
516                check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
517                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
518            }),
519        ];
520
521        let mut content = "HEADING CODE".to_string();
522        let config = Config {
523            global: GlobalConfig::default(),
524            per_file_ignores: HashMap::new(),
525            rules: Default::default(),
526            project_root: None,
527        };
528
529        let result = coordinator
530            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10)
531            .unwrap();
532
533        // Key assertion: all fixes applied in single run
534        assert_eq!(content, "HEADING BLANK FENCE_LANG");
535        assert_eq!(result.rules_fixed, 3);
536        assert!(result.converged);
537    }
538
539    #[test]
540    fn test_unfixable_rules_skipped() {
541        let coordinator = FixCoordinator::new();
542
543        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
544            name: "MD001",
545            check_fn: |content| content.contains("BAD"),
546            fix_fn: |content| content.replace("BAD", "GOOD"),
547        })];
548
549        let mut content = "BAD content".to_string();
550        let mut config = Config {
551            global: GlobalConfig::default(),
552            per_file_ignores: HashMap::new(),
553            rules: Default::default(),
554            project_root: None,
555        };
556        config.global.unfixable = vec!["MD001".to_string()];
557
558        let result = coordinator
559            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
560            .unwrap();
561
562        assert_eq!(content, "BAD content"); // Should not be changed
563        assert_eq!(result.rules_fixed, 0);
564        assert!(result.converged);
565    }
566
567    #[test]
568    fn test_fixable_allowlist() {
569        let coordinator = FixCoordinator::new();
570
571        let rules: Vec<Box<dyn Rule>> = vec![
572            Box::new(ConditionalFixRule {
573                name: "AllowedRule",
574                check_fn: |content| content.contains("A"),
575                fix_fn: |content| content.replace("A", "X"),
576            }),
577            Box::new(ConditionalFixRule {
578                name: "NotAllowedRule",
579                check_fn: |content| content.contains("B"),
580                fix_fn: |content| content.replace("B", "Y"),
581            }),
582        ];
583
584        let mut content = "AB".to_string();
585        let mut config = Config {
586            global: GlobalConfig::default(),
587            per_file_ignores: HashMap::new(),
588            rules: Default::default(),
589            project_root: None,
590        };
591        config.global.fixable = vec!["AllowedRule".to_string()];
592
593        let result = coordinator
594            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
595            .unwrap();
596
597        assert_eq!(content, "XB"); // Only A->X, B unchanged
598        assert_eq!(result.rules_fixed, 1);
599    }
600
601    #[test]
602    fn test_max_iterations_limit() {
603        let coordinator = FixCoordinator::new();
604
605        // Rule that always changes content (pathological case)
606        static COUNTER: AtomicUsize = AtomicUsize::new(0);
607
608        #[derive(Clone)]
609        struct AlwaysChangeRule;
610        impl Rule for AlwaysChangeRule {
611            fn name(&self) -> &'static str {
612                "AlwaysChange"
613            }
614            fn check(&self, _: &LintContext) -> LintResult {
615                Ok(vec![LintWarning {
616                    line: 1,
617                    column: 1,
618                    end_line: 1,
619                    end_column: 1,
620                    message: "Always".to_string(),
621                    rule_name: Some("AlwaysChange".to_string()),
622                    severity: Severity::Error,
623                    fix: Some(Fix {
624                        range: 0..0,
625                        replacement: String::new(),
626                    }),
627                }])
628            }
629            fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
630                COUNTER.fetch_add(1, Ordering::SeqCst);
631                Ok(format!("{}x", ctx.content))
632            }
633            fn description(&self) -> &'static str {
634                "Always changes"
635            }
636            fn category(&self) -> RuleCategory {
637                RuleCategory::Whitespace
638            }
639            fn as_any(&self) -> &dyn std::any::Any {
640                self
641            }
642        }
643
644        COUNTER.store(0, Ordering::SeqCst);
645        let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
646
647        let mut content = "test".to_string();
648        let config = Config {
649            global: GlobalConfig::default(),
650            per_file_ignores: HashMap::new(),
651            rules: Default::default(),
652            project_root: None,
653        };
654
655        let result = coordinator
656            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
657            .unwrap();
658
659        // Should stop at max iterations
660        assert_eq!(result.iterations, 5);
661        assert!(!result.converged);
662        assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
663    }
664
665    #[test]
666    fn test_empty_rules() {
667        let coordinator = FixCoordinator::new();
668        let rules: Vec<Box<dyn Rule>> = vec![];
669
670        let mut content = "unchanged".to_string();
671        let config = Config {
672            global: GlobalConfig::default(),
673            per_file_ignores: HashMap::new(),
674            rules: Default::default(),
675            project_root: None,
676        };
677
678        let result = coordinator
679            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
680            .unwrap();
681
682        assert_eq!(result.rules_fixed, 0);
683        assert_eq!(result.iterations, 1);
684        assert!(result.converged);
685        assert_eq!(content, "unchanged");
686    }
687
688    #[test]
689    fn test_no_warnings_no_changes() {
690        let coordinator = FixCoordinator::new();
691
692        // Rule that finds no issues
693        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
694            name: "NoIssues",
695            check_fn: |_| false, // Never finds issues
696            fix_fn: |content| content.to_string(),
697        })];
698
699        let mut content = "clean content".to_string();
700        let config = Config {
701            global: GlobalConfig::default(),
702            per_file_ignores: HashMap::new(),
703            rules: Default::default(),
704            project_root: None,
705        };
706
707        let result = coordinator
708            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5)
709            .unwrap();
710
711        assert_eq!(content, "clean content");
712        assert_eq!(result.rules_fixed, 0);
713        assert!(result.converged);
714    }
715
716    #[test]
717    fn test_cyclic_dependencies_handled() {
718        let mut coordinator = FixCoordinator::new();
719
720        // Create a cycle: A -> B -> C -> A
721        coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
722        coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
723        coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
724
725        let rules: Vec<Box<dyn Rule>> = vec![
726            Box::new(MockRule {
727                name: "RuleA",
728                warnings: vec![],
729                fix_content: "".to_string(),
730            }),
731            Box::new(MockRule {
732                name: "RuleB",
733                warnings: vec![],
734                fix_content: "".to_string(),
735            }),
736            Box::new(MockRule {
737                name: "RuleC",
738                warnings: vec![],
739                fix_content: "".to_string(),
740            }),
741        ];
742
743        // Should not panic or infinite loop
744        let ordered = coordinator.get_optimal_order(&rules);
745
746        // Should return all rules despite cycle
747        assert_eq!(ordered.len(), 3);
748    }
749
750    #[test]
751    fn test_fix_is_idempotent() {
752        // This is the key test for issue #271
753        let coordinator = FixCoordinator::new();
754
755        let rules: Vec<Box<dyn Rule>> = vec![
756            Box::new(ConditionalFixRule {
757                name: "Rule1",
758                check_fn: |content| content.contains("A"),
759                fix_fn: |content| content.replace("A", "B"),
760            }),
761            Box::new(ConditionalFixRule {
762                name: "Rule2",
763                check_fn: |content| content.contains("B") && !content.contains("C"),
764                fix_fn: |content| content.replace("B", "BC"),
765            }),
766        ];
767
768        let config = Config {
769            global: GlobalConfig::default(),
770            per_file_ignores: HashMap::new(),
771            rules: Default::default(),
772            project_root: None,
773        };
774
775        // First run
776        let mut content1 = "A".to_string();
777        let result1 = coordinator
778            .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10)
779            .unwrap();
780
781        // Second run on same final content
782        let mut content2 = content1.clone();
783        let result2 = coordinator
784            .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10)
785            .unwrap();
786
787        // Should be identical (idempotent)
788        assert_eq!(content1, content2);
789        assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
790        assert!(result1.converged);
791        assert!(result2.converged);
792    }
793}