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