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        // MD005/MD077 (list indent and continuation indent) MUST run before:
94        // - MD032 (blanks around lists) - MD005/MD077 fix nesting structure that MD032 relies on
95        //   to correctly identify list block boundaries; running MD032 first on under-indented
96        //   content causes spurious blank-line insertions inside list items
97        dependencies.insert("MD005", vec!["MD032"]);
98        dependencies.insert("MD077", vec!["MD032"]);
99
100        Self { dependencies }
101    }
102
103    /// Get the optimal order for running rules based on dependencies
104    pub fn get_optimal_order<'a>(&self, rules: &'a [Box<dyn Rule>]) -> Vec<&'a dyn Rule> {
105        // Build a map of rule names to rules for quick lookup
106        let rule_map: HashMap<&str, &dyn Rule> = rules.iter().map(|r| (r.name(), r.as_ref())).collect();
107
108        // Build reverse dependencies (rule -> rules it depends on)
109        let mut reverse_deps: HashMap<&str, HashSet<&str>> = HashMap::new();
110        for (prereq, dependents) in &self.dependencies {
111            for dependent in dependents {
112                reverse_deps.entry(dependent).or_default().insert(prereq);
113            }
114        }
115
116        // Perform topological sort
117        let mut sorted = Vec::new();
118        let mut visited: HashSet<&str> = HashSet::new();
119        let mut visiting: HashSet<&str> = HashSet::new();
120
121        fn visit<'a, 'b>(
122            rule_name: &'b str,
123            rule_map: &HashMap<&str, &'a dyn Rule>,
124            reverse_deps: &HashMap<&'b str, HashSet<&'b str>>,
125            visited: &mut HashSet<&'b str>,
126            visiting: &mut HashSet<&'b str>,
127            sorted: &mut Vec<&'a dyn Rule>,
128        ) where
129            'a: 'b,
130        {
131            if visited.contains(rule_name) {
132                return;
133            }
134
135            if visiting.contains(rule_name) {
136                // Cycle detected, but we'll just skip it
137                return;
138            }
139
140            visiting.insert(rule_name);
141
142            // Visit dependencies first
143            if let Some(deps) = reverse_deps.get(rule_name) {
144                for dep in deps {
145                    if rule_map.contains_key(dep) {
146                        visit(dep, rule_map, reverse_deps, visited, visiting, sorted);
147                    }
148                }
149            }
150
151            visiting.remove(rule_name);
152            visited.insert(rule_name);
153
154            // Add this rule to sorted list
155            if let Some(&rule) = rule_map.get(rule_name) {
156                sorted.push(rule);
157            }
158        }
159
160        // Visit all rules
161        for rule in rules {
162            visit(
163                rule.name(),
164                &rule_map,
165                &reverse_deps,
166                &mut visited,
167                &mut visiting,
168                &mut sorted,
169            );
170        }
171
172        // Add any rules not in dependency graph
173        for rule in rules {
174            if !sorted.iter().any(|r| r.name() == rule.name()) {
175                sorted.push(rule.as_ref());
176            }
177        }
178
179        sorted
180    }
181
182    /// Apply fixes iteratively until no more fixes are needed or max iterations reached.
183    ///
184    /// This implements a Ruff-inspired fix loop that re-checks ALL rules after each fix
185    /// to detect cascading issues (e.g., MD046 creating code blocks that MD040 needs to fix).
186    ///
187    /// The `file_path` parameter is used to determine per-file flavor overrides. If provided,
188    /// the flavor for creating LintContext will be resolved using `config.get_flavor_for_file()`.
189    pub fn apply_fixes_iterative(
190        &self,
191        rules: &[Box<dyn Rule>],
192        _all_warnings: &[LintWarning], // Kept for API compatibility, but we re-check all rules
193        content: &mut String,
194        config: &Config,
195        max_iterations: usize,
196        file_path: Option<&std::path::Path>,
197    ) -> Result<FixResult, String> {
198        // Use the minimum of max_iterations parameter and MAX_ITERATIONS constant
199        let max_iterations = max_iterations.min(MAX_ITERATIONS);
200
201        // Get optimal rule order based on dependencies
202        let ordered_rules = self.get_optimal_order(rules);
203
204        let mut total_fixed = 0;
205        let mut total_ctx_creations = 0;
206        let mut iterations = 0;
207
208        // History tracks (content_hash, rule_that_produced_this_state).
209        // The initial entry has an empty rule name (no rule produced the initial content).
210        let mut history: Vec<(u64, &str)> = vec![(hash_content(content), "")];
211
212        // Track which rules actually applied fixes
213        let mut fixed_rule_names: HashSet<&str> = HashSet::new();
214
215        // Build set of unfixable rules for quick lookup, resolving aliases to canonical IDs
216        let unfixable_rules: HashSet<String> = config
217            .global
218            .unfixable
219            .iter()
220            .map(|s| crate::config::resolve_rule_name(s))
221            .collect();
222
223        // Build set of fixable rules (if specified), resolving aliases to canonical IDs
224        let fixable_rules: HashSet<String> = config
225            .global
226            .fixable
227            .iter()
228            .map(|s| crate::config::resolve_rule_name(s))
229            .collect();
230        let has_fixable_allowlist = !fixable_rules.is_empty();
231
232        // Ruff-style fix loop: keep applying fixes until content stabilizes
233        while iterations < max_iterations {
234            iterations += 1;
235
236            // Create fresh context for this iteration
237            // Use per-file flavor if file_path is provided, otherwise fall back to global flavor
238            let flavor = file_path.map_or_else(|| config.markdown_flavor(), |p| config.get_flavor_for_file(p));
239            let ctx = LintContext::new(content, flavor, file_path.map(std::path::Path::to_path_buf));
240            total_ctx_creations += 1;
241
242            let mut any_fix_applied = false;
243            // The rule that applied a fix this iteration (used for cycle reporting).
244            let mut this_iter_rule: &str = "";
245
246            // Check and fix each rule in dependency order
247            for rule in &ordered_rules {
248                // Skip disabled rules
249                if unfixable_rules.contains(rule.name()) {
250                    continue;
251                }
252                if has_fixable_allowlist && !fixable_rules.contains(rule.name()) {
253                    continue;
254                }
255
256                // Skip rules that indicate they should be skipped (opt-in rules, content-based skipping)
257                if rule.should_skip(&ctx) {
258                    continue;
259                }
260
261                // Check if this rule has any current warnings
262                let Ok(warnings) = rule.check(&ctx) else {
263                    continue;
264                };
265
266                if warnings.is_empty() {
267                    continue;
268                }
269
270                // Filter warnings through inline config to respect disable comments
271                let inline_config = ctx.inline_config();
272                let filtered_warnings =
273                    crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, inline_config, rule.name());
274
275                if filtered_warnings.is_empty() {
276                    continue;
277                }
278
279                // Check if any non-disabled warnings are fixable
280                let has_fixable = filtered_warnings.iter().any(|w| w.fix.is_some());
281                if !has_fixable {
282                    continue;
283                }
284
285                // Apply fix
286                match rule.fix(&ctx) {
287                    Ok(fixed_content) => {
288                        if fixed_content != *content {
289                            *content = fixed_content;
290                            total_fixed += 1;
291                            any_fix_applied = true;
292                            this_iter_rule = rule.name();
293                            fixed_rule_names.insert(rule.name());
294
295                            // Break to re-check all rules with the new content
296                            // This is the key difference from the old approach:
297                            // we always restart from the beginning after a fix
298                            break;
299                        }
300                    }
301                    Err(_) => {
302                        // Error applying fix, continue to next rule
303                        continue;
304                    }
305                }
306            }
307
308            let current_hash = hash_content(content);
309
310            // Check whether this content state has been seen before.
311            if let Some(cycle_start) = history.iter().position(|(h, _)| *h == current_hash) {
312                if cycle_start == history.len() - 1 {
313                    // Content matches the last recorded state: nothing changed this iteration.
314                    return Ok(FixResult {
315                        rules_fixed: total_fixed,
316                        iterations,
317                        context_creations: total_ctx_creations,
318                        fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
319                        converged: true,
320                        conflicting_rules: Vec::new(),
321                        conflict_cycle: Vec::new(),
322                    });
323                } else {
324                    // Content matches an older state: oscillation cycle detected.
325                    // Collect the rules that participate in the cycle.
326                    let conflict_cycle: Vec<String> = history[cycle_start + 1..]
327                        .iter()
328                        .map(|(_, r)| r.to_string())
329                        .chain(std::iter::once(this_iter_rule.to_string()))
330                        .filter(|r| !r.is_empty())
331                        .collect();
332                    let conflicting_rules: Vec<String> = history[cycle_start + 1..]
333                        .iter()
334                        .map(|(_, r)| *r)
335                        .chain(std::iter::once(this_iter_rule))
336                        .filter(|r| !r.is_empty())
337                        .collect::<HashSet<&str>>()
338                        .into_iter()
339                        .map(std::string::ToString::to_string)
340                        .collect();
341                    return Ok(FixResult {
342                        rules_fixed: total_fixed,
343                        iterations,
344                        context_creations: total_ctx_creations,
345                        fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
346                        converged: false,
347                        conflicting_rules,
348                        conflict_cycle,
349                    });
350                }
351            }
352
353            // New state - record it.
354            history.push((current_hash, this_iter_rule));
355
356            // If no fix was applied this iteration, content is stable.
357            if !any_fix_applied {
358                return Ok(FixResult {
359                    rules_fixed: total_fixed,
360                    iterations,
361                    context_creations: total_ctx_creations,
362                    fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
363                    converged: true,
364                    conflicting_rules: Vec::new(),
365                    conflict_cycle: Vec::new(),
366                });
367            }
368        }
369
370        // Hit max iterations without detecting a cycle.
371        Ok(FixResult {
372            rules_fixed: total_fixed,
373            iterations,
374            context_creations: total_ctx_creations,
375            fixed_rule_names: fixed_rule_names.iter().map(std::string::ToString::to_string).collect(),
376            converged: false,
377            conflicting_rules: Vec::new(),
378            conflict_cycle: Vec::new(),
379        })
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
387    use std::sync::atomic::{AtomicUsize, Ordering};
388
389    /// Mock rule that checks content and applies fixes based on a condition
390    #[derive(Clone)]
391    struct ConditionalFixRule {
392        name: &'static str,
393        /// Function to check if content has issues
394        check_fn: fn(&str) -> bool,
395        /// Function to fix content
396        fix_fn: fn(&str) -> String,
397    }
398
399    impl Rule for ConditionalFixRule {
400        fn name(&self) -> &'static str {
401            self.name
402        }
403
404        fn check(&self, ctx: &LintContext) -> LintResult {
405            if (self.check_fn)(ctx.content) {
406                Ok(vec![LintWarning {
407                    line: 1,
408                    column: 1,
409                    end_line: 1,
410                    end_column: 1,
411                    message: format!("{} issue found", self.name),
412                    rule_name: Some(self.name.to_string()),
413                    severity: Severity::Error,
414                    fix: Some(Fix {
415                        range: 0..0,
416                        replacement: String::new(),
417                    }),
418                }])
419            } else {
420                Ok(vec![])
421            }
422        }
423
424        fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
425            Ok((self.fix_fn)(ctx.content))
426        }
427
428        fn description(&self) -> &'static str {
429            "Conditional fix rule for testing"
430        }
431
432        fn category(&self) -> RuleCategory {
433            RuleCategory::Whitespace
434        }
435
436        fn as_any(&self) -> &dyn std::any::Any {
437            self
438        }
439    }
440
441    // Simple mock rule for basic tests
442    #[derive(Clone)]
443    struct MockRule {
444        name: &'static str,
445        warnings: Vec<LintWarning>,
446        fix_content: String,
447    }
448
449    impl Rule for MockRule {
450        fn name(&self) -> &'static str {
451            self.name
452        }
453
454        fn check(&self, _ctx: &LintContext) -> LintResult {
455            Ok(self.warnings.clone())
456        }
457
458        fn fix(&self, _ctx: &LintContext) -> Result<String, LintError> {
459            Ok(self.fix_content.clone())
460        }
461
462        fn description(&self) -> &'static str {
463            "Mock rule for testing"
464        }
465
466        fn category(&self) -> RuleCategory {
467            RuleCategory::Whitespace
468        }
469
470        fn as_any(&self) -> &dyn std::any::Any {
471            self
472        }
473    }
474
475    #[test]
476    fn test_dependency_ordering() {
477        let coordinator = FixCoordinator::new();
478
479        let rules: Vec<Box<dyn Rule>> = vec![
480            Box::new(MockRule {
481                name: "MD009",
482                warnings: vec![],
483                fix_content: "".to_string(),
484            }),
485            Box::new(MockRule {
486                name: "MD013",
487                warnings: vec![],
488                fix_content: "".to_string(),
489            }),
490            Box::new(MockRule {
491                name: "MD010",
492                warnings: vec![],
493                fix_content: "".to_string(),
494            }),
495            Box::new(MockRule {
496                name: "MD007",
497                warnings: vec![],
498                fix_content: "".to_string(),
499            }),
500        ];
501
502        let ordered = coordinator.get_optimal_order(&rules);
503        let ordered_names: Vec<&str> = ordered.iter().map(|r| r.name()).collect();
504
505        // MD010 should come before MD007 (dependency)
506        let md010_idx = ordered_names.iter().position(|&n| n == "MD010").unwrap();
507        let md007_idx = ordered_names.iter().position(|&n| n == "MD007").unwrap();
508        assert!(md010_idx < md007_idx, "MD010 should come before MD007");
509
510        // MD013 should come before MD009 (dependency)
511        let md013_idx = ordered_names.iter().position(|&n| n == "MD013").unwrap();
512        let md009_idx = ordered_names.iter().position(|&n| n == "MD009").unwrap();
513        assert!(md013_idx < md009_idx, "MD013 should come before MD009");
514    }
515
516    #[test]
517    fn test_single_rule_fix() {
518        let coordinator = FixCoordinator::new();
519
520        // Rule that removes "BAD" from content
521        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
522            name: "RemoveBad",
523            check_fn: |content| content.contains("BAD"),
524            fix_fn: |content| content.replace("BAD", "GOOD"),
525        })];
526
527        let mut content = "This is BAD content".to_string();
528        let config = Config::default();
529
530        let result = coordinator
531            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
532            .unwrap();
533
534        assert_eq!(content, "This is GOOD content");
535        assert_eq!(result.rules_fixed, 1);
536        assert!(result.converged);
537    }
538
539    #[test]
540    fn test_cascading_fixes() {
541        // Simulates MD046 -> MD040 cascade:
542        // Rule1: converts "INDENT" to "FENCE" (like MD046 converting indented to fenced)
543        // Rule2: converts "FENCE" to "FENCE_LANG" (like MD040 adding language)
544        let coordinator = FixCoordinator::new();
545
546        let rules: Vec<Box<dyn Rule>> = vec![
547            Box::new(ConditionalFixRule {
548                name: "Rule1_IndentToFence",
549                check_fn: |content| content.contains("INDENT"),
550                fix_fn: |content| content.replace("INDENT", "FENCE"),
551            }),
552            Box::new(ConditionalFixRule {
553                name: "Rule2_FenceToLang",
554                check_fn: |content| content.contains("FENCE") && !content.contains("FENCE_LANG"),
555                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
556            }),
557        ];
558
559        let mut content = "Code: INDENT".to_string();
560        let config = Config::default();
561
562        let result = coordinator
563            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
564            .unwrap();
565
566        // Should reach final state in one run (internally multiple iterations)
567        assert_eq!(content, "Code: FENCE_LANG");
568        assert_eq!(result.rules_fixed, 2);
569        assert!(result.converged);
570        assert!(result.iterations >= 2, "Should take at least 2 iterations for cascade");
571    }
572
573    #[test]
574    fn test_indirect_cascade() {
575        // Simulates MD022 -> MD046 -> MD040 indirect cascade:
576        // Rule1: adds "BLANK" (like MD022 adding blank line)
577        // Rule2: only triggers if "BLANK" present, converts "CODE" to "FENCE"
578        // Rule3: converts "FENCE" to "FENCE_LANG"
579        let coordinator = FixCoordinator::new();
580
581        let rules: Vec<Box<dyn Rule>> = vec![
582            Box::new(ConditionalFixRule {
583                name: "Rule1_AddBlank",
584                check_fn: |content| content.contains("HEADING") && !content.contains("BLANK"),
585                fix_fn: |content| content.replace("HEADING", "HEADING BLANK"),
586            }),
587            Box::new(ConditionalFixRule {
588                name: "Rule2_CodeToFence",
589                // Only detects CODE as issue if BLANK is present (simulates CommonMark rule)
590                check_fn: |content| content.contains("BLANK") && content.contains("CODE"),
591                fix_fn: |content| content.replace("CODE", "FENCE"),
592            }),
593            Box::new(ConditionalFixRule {
594                name: "Rule3_AddLang",
595                check_fn: |content| content.contains("FENCE") && !content.contains("LANG"),
596                fix_fn: |content| content.replace("FENCE", "FENCE_LANG"),
597            }),
598        ];
599
600        let mut content = "HEADING CODE".to_string();
601        let config = Config::default();
602
603        let result = coordinator
604            .apply_fixes_iterative(&rules, &[], &mut content, &config, 10, None)
605            .unwrap();
606
607        // Key assertion: all fixes applied in single run
608        assert_eq!(content, "HEADING BLANK FENCE_LANG");
609        assert_eq!(result.rules_fixed, 3);
610        assert!(result.converged);
611    }
612
613    #[test]
614    fn test_unfixable_rules_skipped() {
615        let coordinator = FixCoordinator::new();
616
617        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
618            name: "MD001",
619            check_fn: |content| content.contains("BAD"),
620            fix_fn: |content| content.replace("BAD", "GOOD"),
621        })];
622
623        let mut content = "BAD content".to_string();
624        let mut config = Config::default();
625        config.global.unfixable = vec!["MD001".to_string()];
626
627        let result = coordinator
628            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
629            .unwrap();
630
631        assert_eq!(content, "BAD content"); // Should not be changed
632        assert_eq!(result.rules_fixed, 0);
633        assert!(result.converged);
634    }
635
636    #[test]
637    fn test_fixable_allowlist() {
638        let coordinator = FixCoordinator::new();
639
640        let rules: Vec<Box<dyn Rule>> = vec![
641            Box::new(ConditionalFixRule {
642                name: "MD001",
643                check_fn: |content| content.contains('A'),
644                fix_fn: |content| content.replace('A', "X"),
645            }),
646            Box::new(ConditionalFixRule {
647                name: "MD002",
648                check_fn: |content| content.contains('B'),
649                fix_fn: |content| content.replace('B', "Y"),
650            }),
651        ];
652
653        let mut content = "AB".to_string();
654        let mut config = Config::default();
655        config.global.fixable = vec!["MD001".to_string()];
656
657        let result = coordinator
658            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
659            .unwrap();
660
661        assert_eq!(content, "XB"); // Only A->X, B unchanged
662        assert_eq!(result.rules_fixed, 1);
663    }
664
665    #[test]
666    fn test_unfixable_rules_resolved_from_alias() {
667        let coordinator = FixCoordinator::new();
668
669        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
670            name: "MD001",
671            check_fn: |content| content.contains("BAD"),
672            fix_fn: |content| content.replace("BAD", "GOOD"),
673        })];
674
675        let mut content = "BAD content".to_string();
676        let mut config = Config::default();
677        // Use the alias instead of canonical name
678        config.global.unfixable = vec!["heading-increment".to_string()];
679
680        let result = coordinator
681            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
682            .unwrap();
683
684        assert_eq!(content, "BAD content"); // Should not be changed - alias resolved to MD001
685        assert_eq!(result.rules_fixed, 0);
686        assert!(result.converged);
687    }
688
689    #[test]
690    fn test_fixable_allowlist_resolved_from_alias() {
691        let coordinator = FixCoordinator::new();
692
693        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
694            name: "MD001",
695            check_fn: |content| content.contains("BAD"),
696            fix_fn: |content| content.replace("BAD", "GOOD"),
697        })];
698
699        let mut content = "BAD content".to_string();
700        let mut config = Config::default();
701        // Use the alias instead of canonical name
702        config.global.fixable = vec!["heading-increment".to_string()];
703
704        let result = coordinator
705            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
706            .unwrap();
707
708        assert_eq!(content, "GOOD content"); // Alias resolved, rule is in allowlist
709        assert_eq!(result.rules_fixed, 1);
710    }
711
712    #[test]
713    fn test_max_iterations_limit() {
714        let coordinator = FixCoordinator::new();
715
716        // Rule that always changes content (pathological case)
717        static COUNTER: AtomicUsize = AtomicUsize::new(0);
718
719        #[derive(Clone)]
720        struct AlwaysChangeRule;
721        impl Rule for AlwaysChangeRule {
722            fn name(&self) -> &'static str {
723                "AlwaysChange"
724            }
725            fn check(&self, _: &LintContext) -> LintResult {
726                Ok(vec![LintWarning {
727                    line: 1,
728                    column: 1,
729                    end_line: 1,
730                    end_column: 1,
731                    message: "Always".to_string(),
732                    rule_name: Some("AlwaysChange".to_string()),
733                    severity: Severity::Error,
734                    fix: Some(Fix {
735                        range: 0..0,
736                        replacement: String::new(),
737                    }),
738                }])
739            }
740            fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
741                COUNTER.fetch_add(1, Ordering::SeqCst);
742                Ok(format!("{}x", ctx.content))
743            }
744            fn description(&self) -> &'static str {
745                "Always changes"
746            }
747            fn category(&self) -> RuleCategory {
748                RuleCategory::Whitespace
749            }
750            fn as_any(&self) -> &dyn std::any::Any {
751                self
752            }
753        }
754
755        COUNTER.store(0, Ordering::SeqCst);
756        let rules: Vec<Box<dyn Rule>> = vec![Box::new(AlwaysChangeRule)];
757
758        let mut content = "test".to_string();
759        let config = Config::default();
760
761        let result = coordinator
762            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
763            .unwrap();
764
765        // Should stop at max iterations
766        assert_eq!(result.iterations, 5);
767        assert!(!result.converged);
768        assert_eq!(COUNTER.load(Ordering::SeqCst), 5);
769    }
770
771    #[test]
772    fn test_empty_rules() {
773        let coordinator = FixCoordinator::new();
774        let rules: Vec<Box<dyn Rule>> = vec![];
775
776        let mut content = "unchanged".to_string();
777        let config = Config::default();
778
779        let result = coordinator
780            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
781            .unwrap();
782
783        assert_eq!(result.rules_fixed, 0);
784        assert_eq!(result.iterations, 1);
785        assert!(result.converged);
786        assert_eq!(content, "unchanged");
787    }
788
789    #[test]
790    fn test_no_warnings_no_changes() {
791        let coordinator = FixCoordinator::new();
792
793        // Rule that finds no issues
794        let rules: Vec<Box<dyn Rule>> = vec![Box::new(ConditionalFixRule {
795            name: "NoIssues",
796            check_fn: |_| false, // Never finds issues
797            fix_fn: |content| content.to_string(),
798        })];
799
800        let mut content = "clean content".to_string();
801        let config = Config::default();
802
803        let result = coordinator
804            .apply_fixes_iterative(&rules, &[], &mut content, &config, 5, None)
805            .unwrap();
806
807        assert_eq!(content, "clean content");
808        assert_eq!(result.rules_fixed, 0);
809        assert!(result.converged);
810    }
811
812    #[test]
813    fn test_oscillation_detection() {
814        // Two rules that fight each other: Rule A changes "foo" → "bar", Rule B changes "bar" → "foo".
815        // The fix loop should detect this as an oscillation cycle and stop early with
816        // conflicting_rules populated rather than running all 100 iterations.
817        let coordinator = FixCoordinator::new();
818
819        let rules: Vec<Box<dyn Rule>> = vec![
820            Box::new(ConditionalFixRule {
821                name: "RuleA",
822                check_fn: |content| content.contains("foo"),
823                fix_fn: |content| content.replace("foo", "bar"),
824            }),
825            Box::new(ConditionalFixRule {
826                name: "RuleB",
827                check_fn: |content| content.contains("bar"),
828                fix_fn: |content| content.replace("bar", "foo"),
829            }),
830        ];
831
832        let mut content = "foo".to_string();
833        let config = Config::default();
834
835        let result = coordinator
836            .apply_fixes_iterative(&rules, &[], &mut content, &config, 100, None)
837            .unwrap();
838
839        // Should detect the cycle quickly, not burn through all 100 iterations.
840        assert!(!result.converged, "Should not converge in an oscillating pair");
841        assert!(
842            result.iterations < 10,
843            "Cycle detection should stop well before max_iterations (got {})",
844            result.iterations
845        );
846
847        // Both conflicting rules should be identified.
848        let mut conflicting = result.conflicting_rules.clone();
849        conflicting.sort();
850        assert_eq!(
851            conflicting,
852            vec!["RuleA".to_string(), "RuleB".to_string()],
853            "Both oscillating rules must be reported"
854        );
855        assert_eq!(
856            result.conflict_cycle,
857            vec!["RuleA".to_string(), "RuleB".to_string()],
858            "Cycle should preserve the observed application order"
859        );
860    }
861
862    #[test]
863    fn test_cyclic_dependencies_handled() {
864        let mut coordinator = FixCoordinator::new();
865
866        // Create a cycle: A -> B -> C -> A
867        coordinator.dependencies.insert("RuleA", vec!["RuleB"]);
868        coordinator.dependencies.insert("RuleB", vec!["RuleC"]);
869        coordinator.dependencies.insert("RuleC", vec!["RuleA"]);
870
871        let rules: Vec<Box<dyn Rule>> = vec![
872            Box::new(MockRule {
873                name: "RuleA",
874                warnings: vec![],
875                fix_content: "".to_string(),
876            }),
877            Box::new(MockRule {
878                name: "RuleB",
879                warnings: vec![],
880                fix_content: "".to_string(),
881            }),
882            Box::new(MockRule {
883                name: "RuleC",
884                warnings: vec![],
885                fix_content: "".to_string(),
886            }),
887        ];
888
889        // Should not panic or infinite loop
890        let ordered = coordinator.get_optimal_order(&rules);
891
892        // Should return all rules despite cycle
893        assert_eq!(ordered.len(), 3);
894    }
895
896    #[test]
897    fn test_fix_is_idempotent() {
898        // This is the key test for issue #271
899        let coordinator = FixCoordinator::new();
900
901        let rules: Vec<Box<dyn Rule>> = vec![
902            Box::new(ConditionalFixRule {
903                name: "Rule1",
904                check_fn: |content| content.contains('A'),
905                fix_fn: |content| content.replace('A', "B"),
906            }),
907            Box::new(ConditionalFixRule {
908                name: "Rule2",
909                check_fn: |content| content.contains('B') && !content.contains('C'),
910                fix_fn: |content| content.replace('B', "BC"),
911            }),
912        ];
913
914        let config = Config::default();
915
916        // First run
917        let mut content1 = "A".to_string();
918        let result1 = coordinator
919            .apply_fixes_iterative(&rules, &[], &mut content1, &config, 10, None)
920            .unwrap();
921
922        // Second run on same final content
923        let mut content2 = content1.clone();
924        let result2 = coordinator
925            .apply_fixes_iterative(&rules, &[], &mut content2, &config, 10, None)
926            .unwrap();
927
928        // Should be identical (idempotent)
929        assert_eq!(content1, content2);
930        assert_eq!(result2.rules_fixed, 0, "Second run should fix nothing");
931        assert!(result1.converged);
932        assert!(result2.converged);
933    }
934}