Skip to main content

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    /// Rules identified as participants in an oscillation cycle.
28    /// Populated only when `converged == false` and a cycle was detected.
29    /// Empty when the fix loop hit `max_iterations` without cycling.
30    pub conflicting_rules: Vec<String>,
31    /// Ordered rule sequence observed in the cycle.
32    /// If non-empty, this can be rendered as a loop by appending the first rule
33    /// at the end (e.g. `MD044 -> MD063 -> MD044`).
34    pub conflict_cycle: Vec<String>,
35}
36
37/// Calculate hash of content for convergence detection
38fn hash_content(content: &str) -> u64 {
39    let mut hasher = DefaultHasher::new();
40    content.hash(&mut hasher);
41    hasher.finish()
42}
43
44/// Coordinates rule fixing to minimize the number of passes needed
45pub struct FixCoordinator {
46    /// Rules that should run before others (rule -> rules that depend on it)
47    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        // CRITICAL DEPENDENCIES:
61        // These dependencies prevent cascading issues that require multiple passes
62
63        // MD064 (multiple consecutive spaces) MUST run before:
64        // - MD010 (tabs->spaces) - MD010 replaces tabs with multiple spaces (e.g., 4),
65        //   which MD064 would incorrectly collapse back to 1 space if it ran after
66        dependencies.insert("MD064", vec!["MD010"]);
67
68        // MD010 (tabs->spaces) MUST run before:
69        // - MD007 (list indentation) - because tabs affect indent calculation
70        // - MD005 (list indent consistency) - same reason
71        dependencies.insert("MD010", vec!["MD007", "MD005"]);
72
73        // MD013 (line length) MUST run before:
74        // - MD009 (trailing spaces) - line wrapping might add trailing spaces that need cleanup
75        // - MD012 (multiple blanks) - reflowing can affect blank lines
76        // Note: MD013 now trims trailing whitespace during reflow to prevent mid-line spaces
77        dependencies.insert("MD013", vec!["MD009", "MD012"]);
78
79        // MD004 (list style) should run before:
80        // - MD007 (list indentation) - changing markers affects indentation
81        dependencies.insert("MD004", vec!["MD007"]);
82
83        // MD022/MD023 (heading spacing) should run before:
84        // - MD012 (multiple blanks) - heading fixes can affect blank lines
85        dependencies.insert("MD022", vec!["MD012"]);
86        dependencies.insert("MD023", vec!["MD012"]);
87
88        // MD070 (nested fence collision) MUST run before:
89        // - MD040 (code language) - MD070 changes block structure, making orphan fences into content
90        // - MD031 (blanks around fences) - same reason
91        dependencies.insert("MD070", vec!["MD040", "MD031"]);
92
93        Self { dependencies }
94    }
95
96    /// Get the optimal order for running rules based on dependencies
97    pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
98        // Build a map of rule names to rules for quick lookup
99        let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
100
101        // Build reverse dependencies (rule -> rules it depends on)
102        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        // Perform topological sort
110        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                // Cycle detected, but we'll just skip it
128                return;
129            }
130
131            visiting.insert(rule_name.to_string());
132
133            // Visit dependencies first
134            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            // Add this rule to sorted list
146            if let Some(&rule) = rule_map.get(rule_name) {
147                sorted.push(rule);
148            }
149        }
150
151        // Visit all rules
152        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        // Add any rules not in dependency graph
164        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    /// Apply fixes iteratively until no more fixes are needed or max iterations reached.
174    ///
175    /// This implements a Ruff-inspired fix loop that re-checks ALL rules after each fix
176    /// to detect cascading issues (e.g., MD046 creating code blocks that MD040 needs to fix).
177    ///
178    /// The `file_path` parameter is used to determine per-file flavor overrides. If provided,
179    /// the flavor for creating LintContext will be resolved using `config.get_flavor_for_file()`.
180    pub fn apply_fixes_iterative(
181        &self,
182        rules: &[Box<dyn Rule>],
183        _all_warnings: &[LintWarning], // Kept for API compatibility, but we re-check all rules
184        content: &mut String,
185        config: &Config,
186        max_iterations: usize,
187        file_path: Option<&std::path::Path>,
188    ) -> Result<FixResult, String> {
189        // Use the minimum of max_iterations parameter and MAX_ITERATIONS constant
190        let max_iterations = max_iterations.min(MAX_ITERATIONS);
191
192        // Get optimal rule order based on dependencies
193        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        // History tracks (content_hash, rule_that_produced_this_state).
200        // The initial entry has an empty rule name (no rule produced the initial content).
201        let mut history: Vec<(u64, String)> = vec![(hash_content(content), String::new())];
202
203        // Track which rules actually applied fixes
204        let mut fixed_rule_names = HashSet::new();
205
206        // Build set of unfixable rules for quick lookup, resolving aliases to canonical IDs
207        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        // Build set of fixable rules (if specified), resolving aliases to canonical IDs
215        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        // Ruff-style fix loop: keep applying fixes until content stabilizes
224        while iterations < max_iterations {
225            iterations += 1;
226
227            // Create fresh context for this iteration
228            // Use per-file flavor if file_path is provided, otherwise fall back to global flavor
229            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            // The rule that applied a fix this iteration (used for cycle reporting).
237            let mut this_iter_rule = String::new();
238
239            // Check and fix each rule in dependency order
240            for rule in &ordered_rules {
241                // Skip disabled rules
242                if unfixable_rules.contains(rule.name()) {
243                    continue;
244                }
245                if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
246                    continue;
247                }
248
249                // Skip rules that indicate they should be skipped (opt-in rules, content-based skipping)
250                if rule.should_skip(&ctx) {
251                    continue;
252                }
253
254                // Check if this rule has any current warnings
255                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                // Check if any warnings are fixable
265                let has_fixable = warnings.iter().any(|w| w.fix.is_some());
266                if !has_fixable {
267                    continue;
268                }
269
270                // Apply fix
271                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 to re-check all rules with the new content
281                            // This is the key difference from the old approach:
282                            // we always restart from the beginning after a fix
283                            break;
284                        }
285                    }
286                    Err(_) => {
287                        // Error applying fix, continue to next rule
288                        continue;
289                    }
290                }
291            }
292
293            let current_hash = hash_content(content);
294
295            // Check whether this content state has been seen before.
296            if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
297                if cycle_start == history.len() - 1 {
298                    // Content matches the last recorded state: nothing changed this iteration.
299                    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                    // Content matches an older state: oscillation cycle detected.
310                    // Collect the rules that participate in the cycle.
311                    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            // New state - record it.
338            history.push((current_hash, this_iter_rule));
339
340            // If no fix was applied this iteration, content is stable.
341            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        // Hit max iterations without detecting a cycle.
355        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    /// Mock rule that checks content and applies fixes based on a condition
374    #[derive(Clone)]
375    struct ConditionalFixRule {
376        name: &'static str,
377        /// Function to check if content has issues
378        check_fn: fn(&str) -> bool,
379        /// Function to fix content
380        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    // Simple mock rule for basic tests
426    #[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        // MD010 should come before MD007 (dependency)
490        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        // MD013 should come before MD009 (dependency)
495        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        // Rule that removes "BAD" from content
505        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        // Simulates MD046 -> MD040 cascade:
526        // Rule1: converts "INDENT" to "FENCE" (like MD046 converting indented to fenced)
527        // Rule2: converts "FENCE" to "FENCE_LANG" (like MD040 adding language)
528        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        // Should reach final state in one run (internally multiple iterations)
551        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        // Simulates MD022 -> MD046 -> MD040 indirect cascade:
560        // Rule1: adds "BLANK" (like MD022 adding blank line)
561        // Rule2: only triggers if "BLANK" present, converts "CODE" to "FENCE"
562        // Rule3: converts "FENCE" to "FENCE_LANG"
563        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                // Only detects CODE as issue if BLANK is present (simulates CommonMark rule)
574                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        // Key assertion: all fixes applied in single run
592        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"); // Should not be changed
616        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"); // Only A->X, B unchanged
646        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        // Use the alias instead of canonical name
662        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"); // Should not be changed - alias resolved to MD001
669        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        // Use the alias instead of canonical name
686        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"); // Alias resolved, rule is in allowlist
693        assert_eq!(result.rules_fixed, 1);
694    }
695
696    #[test]
697    fn test_max_iterations_limit() {
698        let coordinator = FixCoordinator::new();
699
700        // Rule that always changes content (pathological case)
701        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        // Should stop at max iterations
750        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        // Rule that finds no issues
778        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
779            name: "NoIssues",
780            check_fn: |_| false, // Never finds issues
781            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        // Two rules that fight each other: Rule A changes "foo" → "bar", Rule B changes "bar" → "foo".
799        // The fix loop should detect this as an oscillation cycle and stop early with
800        // conflicting_rules populated rather than running all 100 iterations.
801        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        // Should detect the cycle quickly, not burn through all 100 iterations.
824        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        // Both conflicting rules should be identified.
832        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        // Create a cycle: A -> B -> C -> A
851        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        // Should not panic or infinite loop
874        let ordered = coordinator.get_optimal_order(&rules);
875
876        // Should return all rules despite cycle
877        assert_eq!(ordered.len(), 3);
878    }
879
880    #[test]
881    fn test_fix_is_idempotent() {
882        // This is the key test for issue #271
883        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        // First run
901        let mut content1 = "A".to_string();
902        let result1 = coordinator
903            .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
904            .unwrap();
905
906        // Second run on same final content
907        let mut content2 = content1.clone();
908        let result2 = coordinator
909            .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
910            .unwrap();
911
912        // Should be identical (idempotent)
913        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}