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                // Filter warnings through inline config to respect disable comments
265                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                // Check if any non-disabled warnings are fixable
274                let has_fixable = filtered_warnings.iter().any(|w| w.fix.is_some());
275                if !has_fixable {
276                    continue;
277                }
278
279                // Apply fix
280                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 to re-check all rules with the new content
290                            // This is the key difference from the old approach:
291                            // we always restart from the beginning after a fix
292                            break;
293                        }
294                    }
295                    Err(_) => {
296                        // Error applying fix, continue to next rule
297                        continue;
298                    }
299                }
300            }
301
302            let current_hash = hash_content(content);
303
304            // Check whether this content state has been seen before.
305            if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
306                if cycle_start == history.len() - 1 {
307                    // Content matches the last recorded state: nothing changed this iteration.
308                    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                    // Content matches an older state: oscillation cycle detected.
319                    // Collect the rules that participate in the cycle.
320                    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            // New state - record it.
347            history.push((current_hash, this_iter_rule));
348
349            // If no fix was applied this iteration, content is stable.
350            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        // Hit max iterations without detecting a cycle.
364        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    /// Mock rule that checks content and applies fixes based on a condition
383    #[derive(Clone)]
384    struct ConditionalFixRule {
385        name: &'static str,
386        /// Function to check if content has issues
387        check_fn: fn(&str) -> bool,
388        /// Function to fix content
389        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    // Simple mock rule for basic tests
435    #[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        // MD010 should come before MD007 (dependency)
499        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        // MD013 should come before MD009 (dependency)
504        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        // Rule that removes "BAD" from content
514        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        // Simulates MD046 -> MD040 cascade:
535        // Rule1: converts "INDENT" to "FENCE" (like MD046 converting indented to fenced)
536        // Rule2: converts "FENCE" to "FENCE_LANG" (like MD040 adding language)
537        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        // Should reach final state in one run (internally multiple iterations)
560        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        // Simulates MD022 -> MD046 -> MD040 indirect cascade:
569        // Rule1: adds "BLANK" (like MD022 adding blank line)
570        // Rule2: only triggers if "BLANK" present, converts "CODE" to "FENCE"
571        // Rule3: converts "FENCE" to "FENCE_LANG"
572        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                // Only detects CODE as issue if BLANK is present (simulates CommonMark rule)
583                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        // Key assertion: all fixes applied in single run
601        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"); // Should not be changed
625        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"); // Only A->X, B unchanged
655        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        // Use the alias instead of canonical name
671        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"); // Should not be changed - alias resolved to MD001
678        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        // Use the alias instead of canonical name
695        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"); // Alias resolved, rule is in allowlist
702        assert_eq!(result.rules_fixed, 1);
703    }
704
705    #[test]
706    fn test_max_iterations_limit() {
707        let coordinator = FixCoordinator::new();
708
709        // Rule that always changes content (pathological case)
710        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        // Should stop at max iterations
759        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        // Rule that finds no issues
787        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
788            name: "NoIssues",
789            check_fn: |_| false, // Never finds issues
790            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        // Two rules that fight each other: Rule A changes "foo" → "bar", Rule B changes "bar" → "foo".
808        // The fix loop should detect this as an oscillation cycle and stop early with
809        // conflicting_rules populated rather than running all 100 iterations.
810        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        // Should detect the cycle quickly, not burn through all 100 iterations.
833        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        // Both conflicting rules should be identified.
841        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        // Create a cycle: A -> B -> C -> A
860        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        // Should not panic or infinite loop
883        let ordered = coordinator.get_optimal_order(&rules);
884
885        // Should return all rules despite cycle
886        assert_eq!(ordered.len(), 3);
887    }
888
889    #[test]
890    fn test_fix_is_idempotent() {
891        // This is the key test for issue #271
892        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        // First run
910        let mut content1 = "A".to_string();
911        let result1 = coordinator
912            .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
913            .unwrap();
914
915        // Second run on same final content
916        let mut content2 = content1.clone();
917        let result2 = coordinator
918            .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
919            .unwrap();
920
921        // Should be identical (idempotent)
922        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}