Skip to main content

fix_engine/
engine.rs

1//! Fix engine: maps analysis violations to concrete text edits.
2//!
3//! Two-tier approach:
4//! 1. Pattern-based: deterministic renames/removals driven by incident variables
5//! 2. LLM-assisted: complex structural changes sent to an LLM endpoint
6//!
7//! The engine is language-agnostic. Language-specific operations (attribute
8//! removal, import deduplication, path skipping, dependency management) are
9//! delegated to a [`LanguageFixProvider`](crate::language::LanguageFixProvider)
10//! implementation.
11
12use anyhow::Result;
13use fix_engine_core::*;
14use konveyor_core::incident::Incident;
15use konveyor_core::report::RuleSet;
16use std::collections::{BTreeMap, HashMap};
17use std::path::{Path, PathBuf};
18
19use crate::language::LanguageFixProvider;
20
21/// Build a fix plan from analysis output.
22///
23/// `strategies` is a merged map of rule ID -> fix strategy, loaded from one or
24/// more external JSON files (rule-adjacent and/or semver-analyzer generated).
25/// When no strategy is found for a rule, label-based inference is attempted,
26/// falling back to LLM-assisted fixes.
27///
28/// `lang` provides language-specific fix operations (attribute removal,
29/// matched text extraction, path skipping, dependency management).
30pub fn plan_fixes(
31    output: &[RuleSet],
32    project_root: &std::path::Path,
33    strategies: &BTreeMap<String, FixStrategy>,
34    lang: &dyn LanguageFixProvider,
35) -> Result<FixPlan> {
36    let mut plan = FixPlan::default();
37
38    for ruleset in output {
39        for (rule_id, violation) in &ruleset.violations {
40            // Lookup order: strategies map -> label inference -> LLM fallback
41            let strategy = strategies
42                .get(rule_id.as_str())
43                .cloned()
44                .or_else(|| infer_strategy_from_labels(&violation.labels).cloned())
45                .unwrap_or(FixStrategy::Llm { context: None });
46
47            for incident in &violation.incidents {
48                let file_path = uri_to_path(&incident.file_uri, project_root);
49
50                // Let the language provider decide which paths to skip
51                // (e.g., node_modules for JS/TS projects).
52                if lang.should_skip_path(&file_path) {
53                    continue;
54                }
55
56                match &strategy {
57                    FixStrategy::Rename(mappings) => {
58                        if let Some(mut fix) =
59                            plan_rename(rule_id, incident, mappings, &file_path, lang)
60                        {
61                            // Import renames: the old name appears in both the
62                            // import specifier AND the module path (e.g.,
63                            // `import { OLD } from '.../dist/js/OLD'`). Replace
64                            // all occurrences so both are updated.
65                            if incident.variables.contains_key("importedName") {
66                                for edit in &mut fix.edits {
67                                    edit.replace_all = true;
68                                }
69                            }
70                            plan.files.entry(file_path).or_default().push(fix);
71                        }
72                    }
73                    FixStrategy::RemoveAttribute => {
74                        if let Some(fix) = lang.plan_remove_attribute(rule_id, incident, &file_path)
75                        {
76                            plan.files.entry(file_path).or_default().push(fix);
77                        }
78                    }
79                    FixStrategy::ImportPathChange { old_path, new_path } => {
80                        if let Some(fix) = plan_import_path_change(
81                            rule_id, incident, old_path, new_path, &file_path,
82                        ) {
83                            plan.files.entry(file_path).or_default().push(fix);
84                        }
85                    }
86                    FixStrategy::CssVariablePrefix {
87                        old_prefix,
88                        new_prefix,
89                        exclude_patterns,
90                    } => {
91                        // Check if this incident matches a dead-class exclusion.
92                        // If the matched text contains an excluded pattern (a CSS
93                        // class that was removed, not just prefix-renamed), skip
94                        // the automated prefix swap. The dead-class rule will flag
95                        // this incident for manual review separately.
96                        let matched_text = incident
97                            .variables
98                            .get("matchingText")
99                            .and_then(|v| v.as_str())
100                            .or_else(|| {
101                                incident.variables.get("className").and_then(|v| v.as_str())
102                            })
103                            .unwrap_or("");
104
105                        if !exclude_patterns.is_empty()
106                            && exclude_patterns
107                                .iter()
108                                .any(|excl| matched_text.contains(excl.as_str()))
109                        {
110                            tracing::debug!(
111                                rule_id = %rule_id,
112                                matched = %matched_text,
113                                "Skipping CssVariablePrefix for dead class (excluded pattern)"
114                            );
115                            // Emit as manual review instead
116                            plan.manual.push(ManualFixItem {
117                                rule_id: rule_id.clone(),
118                                file_uri: incident.file_uri.clone(),
119                                line: incident.line_number.unwrap_or(0),
120                                message: format!(
121                                    "CSS class '{}' was removed — prefix swap would produce a non-existent class. {}",
122                                    matched_text,
123                                    incident.message
124                                ),
125                                code_snip: incident.code_snip.clone(),
126                            });
127                            continue;
128                        }
129
130                        // Treat CSS prefix changes as renames
131                        let mappings = vec![RenameMapping {
132                            old: old_prefix.clone(),
133                            new: new_prefix.clone(),
134                        }];
135                        if let Some(mut fix) =
136                            plan_rename(rule_id, incident, &mappings, &file_path, lang)
137                        {
138                            // CSS prefix edits should replace ALL occurrences on a line,
139                            // e.g. className="pf-v5-u-color-200 pf-v5-u-font-weight-light"
140                            for edit in &mut fix.edits {
141                                edit.replace_all = true;
142                            }
143                            plan.files.entry(file_path).or_default().push(fix);
144                        }
145                    }
146                    FixStrategy::EnsureDependency {
147                        ref package,
148                        ref new_version,
149                    } => {
150                        // Delegate to the language provider for ecosystem-specific
151                        // dependency management (package.json, Cargo.toml, go.mod, etc.)
152                        // A single incident may produce multiple fixes (e.g., a lockfile
153                        // incident resolving multiple parent packages to update).
154                        let fixes = lang.plan_ensure_dependency(
155                            rule_id,
156                            incident,
157                            package,
158                            new_version,
159                            &file_path,
160                        );
161                        for fix in fixes {
162                            let dep_file = fix.file_uri.clone();
163                            let dep_path = uri_to_path(&dep_file, project_root);
164                            plan.files.entry(dep_path).or_default().push(fix);
165                        }
166                    }
167                    FixStrategy::Manual => {
168                        plan.manual.push(ManualFixItem {
169                            rule_id: rule_id.clone(),
170                            file_uri: incident.file_uri.clone(),
171                            line: incident.line_number.unwrap_or(0),
172                            message: incident.message.clone(),
173                            code_snip: incident.code_snip.clone(),
174                        });
175                    }
176                    FixStrategy::Llm { ref context } => {
177                        let mut enriched_message = incident.message.clone();
178
179                        // Append incident variables as structured context
180                        // (propName, componentName, propValue, module, etc.)
181                        if !incident.variables.is_empty() {
182                            enriched_message.push_str("\n\nIncident context:");
183                            for (key, value) in &incident.variables {
184                                let val_str = match value {
185                                    serde_json::Value::String(s) => s.clone(),
186                                    other => other.to_string(),
187                                };
188                                enriched_message.push_str(&format!("\n  {}: {}", key, val_str));
189                            }
190                        }
191
192                        // Append strategy context if available (from FixStrategyEntry)
193                        if let Some(ctx) = context {
194                            enriched_message.push_str(&format!("\n\nFix strategy:\n{}", ctx));
195                        }
196
197                        plan.pending_llm.push(LlmFixRequest {
198                            rule_id: rule_id.clone(),
199                            file_uri: incident.file_uri.clone(),
200                            file_path: file_path.clone(),
201                            line: incident.line_number.unwrap_or(0),
202                            message: enriched_message,
203                            code_snip: incident.code_snip.clone(),
204                            source: None, // filled lazily if LLM is invoked
205                            labels: violation.labels.clone(),
206                        });
207                    }
208                }
209            }
210        }
211    }
212
213    // Merge dependency-insert edits: when multiple EnsureDependency fixes
214    // insert new packages before the same closing brace line in package.json,
215    // combine them into a single multi-line insertion. Without this, only the
216    // first insert succeeds — subsequent ones fail because the closing brace
217    // line has already been replaced.
218    merge_dependency_inserts(&mut plan);
219
220    // Sort edits within each file by line number (descending) so we can apply bottom-up
221    for fixes in plan.files.values_mut() {
222        fixes.sort_by_key(|f| std::cmp::Reverse(f.line));
223    }
224
225    // Deduplicate overlapping edits: when multiple edits target the same line
226    // and one edit's old_text is a substring of another's, the more specific
227    // (longer old_text) edit wins. This handles the CSS rule cascade where a
228    // specific rename rule, a prefix stale rule, and a class prefix rule all
229    // target the same variable on the same line.
230    deduplicate_edits(&mut plan);
231
232    Ok(plan)
233}
234
235/// Remove edits that are subsumed by a more specific edit on the same line.
236///
237/// Two edits on the same line are considered overlapping when one's `old_text`
238/// is a substring of the other's. The more specific edit (longer `old_text`)
239/// wins because it produces a more precise replacement. The subsumed edit is
240/// removed from the plan and counted in `plan.edits_subsumed`.
241///
242/// When two edits share the same `old_text` but have different `new_text`
243/// Merge dependency-insert edits that target the same closing brace line.
244///
245/// When multiple `EnsureDependency` fixes each insert a new package before the
246/// closing `}` of a dep block in package.json, they all produce edits with the
247/// same `old_text` (the closing brace line) on the same line number. Only the
248/// first would succeed since the closing brace is replaced. This function
249/// combines them into a single edit that inserts all packages at once.
250fn merge_dependency_inserts(plan: &mut FixPlan) {
251    for (file_path, fixes) in plan.files.iter_mut() {
252        // Only process package.json files
253        if file_path
254            .file_name()
255            .and_then(|f| f.to_str())
256            .filter(|f| *f == "package.json")
257            .is_none()
258        {
259            continue;
260        }
261
262        // Find insert edits: description starts with "Add " and targets a
263        // closing brace line. Group by (line, old_text).
264        let mut insert_groups: std::collections::HashMap<(u32, String), Vec<(usize, usize)>> =
265            std::collections::HashMap::new();
266
267        for (fix_idx, fix) in fixes.iter().enumerate() {
268            for (edit_idx, edit) in fix.edits.iter().enumerate() {
269                if edit.description.starts_with("Add ")
270                    && edit.old_text.trim().starts_with('}')
271                    && edit.new_text.contains(&edit.old_text.trim().to_string())
272                {
273                    insert_groups
274                        .entry((edit.line, edit.old_text.clone()))
275                        .or_default()
276                        .push((fix_idx, edit_idx));
277                }
278            }
279        }
280
281        // For each group with >1 insert, merge into the first edit
282        for ((_line, old_text), indices) in &insert_groups {
283            if indices.len() <= 1 {
284                continue;
285            }
286
287            // Collect the new dependency lines from each edit.
288            // Each edit's new_text is like: `    "pkg": "ver"\n  }`
289            // We extract everything before the closing brace.
290            let closing_trimmed = old_text.trim().to_string();
291            let mut new_entries: Vec<String> = Vec::new();
292
293            for &(fix_idx, edit_idx) in indices {
294                let new_text = &fixes[fix_idx].edits[edit_idx].new_text;
295                // Extract lines before the closing brace
296                if let Some(pos) = new_text.rfind(&closing_trimmed) {
297                    let entries_part = &new_text[..pos];
298                    for entry_line in entries_part.lines() {
299                        let trimmed = entry_line.trim();
300                        if !trimmed.is_empty() {
301                            new_entries.push(entry_line.to_string());
302                        }
303                    }
304                }
305            }
306
307            if new_entries.is_empty() {
308                continue;
309            }
310
311            // Deduplicate entries (same package might appear from multiple rules)
312            let mut seen = std::collections::HashSet::new();
313            new_entries.retain(|e| seen.insert(e.clone()));
314
315            // Build the merged new_text: all entries followed by the closing brace
316            // Preserve the original indentation of the closing brace line.
317            let merged_new_text = format!("{}\n{}", new_entries.join(",\n"), old_text);
318
319            // Update the first edit with the merged text
320            let (first_fix, first_edit) = indices[0];
321            fixes[first_fix].edits[first_edit].new_text = merged_new_text;
322            fixes[first_fix].edits[first_edit].description =
323                format!("Add {} dependencies to package.json", new_entries.len());
324
325            // Remove the other edits by clearing them (they'll be deduped/skipped later)
326            for &(fix_idx, edit_idx) in &indices[1..] {
327                // Mark as no-op: set old_text to something that won't match
328                fixes[fix_idx].edits[edit_idx].old_text =
329                    "__MERGED_DEPENDENCY_INSERT__".to_string();
330            }
331
332            tracing::info!(
333                file = %file_path.display(),
334                count = new_entries.len(),
335                "Merged dependency insert edits into single edit"
336            );
337        }
338    }
339}
340
341/// (conflicting edits), the first in specificity order is kept.
342///
343/// Exact duplicates (same line, old_text, new_text) are also removed.
344fn deduplicate_edits(plan: &mut FixPlan) {
345    let mut total_subsumed: usize = 0;
346
347    for fixes in plan.files.values_mut() {
348        // Collect all edits across all PlannedFixes for this file, tracking
349        // which PlannedFix and edit index they came from.
350        struct EditRef {
351            fix_idx: usize,
352            edit_idx: usize,
353            line: u32,
354            old_text: String,
355            new_text: String,
356        }
357
358        let mut all_edits: Vec<EditRef> = Vec::new();
359        for (fix_idx, fix) in fixes.iter().enumerate() {
360            for (edit_idx, edit) in fix.edits.iter().enumerate() {
361                all_edits.push(EditRef {
362                    fix_idx,
363                    edit_idx,
364                    line: edit.line,
365                    old_text: edit.old_text.clone(),
366                    new_text: edit.new_text.clone(),
367                });
368            }
369        }
370
371        // Group by line number
372        let mut by_line: HashMap<u32, Vec<usize>> = HashMap::new();
373        for (i, er) in all_edits.iter().enumerate() {
374            by_line.entry(er.line).or_default().push(i);
375        }
376
377        // For each line, determine which edits to remove
378        let mut remove_set: std::collections::HashSet<(usize, usize)> =
379            std::collections::HashSet::new();
380
381        for indices in by_line.values() {
382            if indices.len() <= 1 {
383                continue;
384            }
385
386            // Sort by old_text length descending (most specific first),
387            // then by old_text alphabetically for deterministic ordering
388            let mut sorted: Vec<usize> = indices.clone();
389            sorted.sort_by(|&a, &b| {
390                let ea = &all_edits[a];
391                let eb = &all_edits[b];
392                eb.old_text
393                    .len()
394                    .cmp(&ea.old_text.len())
395                    .then_with(|| ea.old_text.cmp(&eb.old_text))
396            });
397
398            // Walk in specificity order. Keep the first edit for each
399            // non-overlapping text region. Subsume edits whose old_text
400            // is a substring of a kept edit's old_text, or whose old_text
401            // matches a kept edit's old_text (conflict: first wins).
402            let mut kept: Vec<usize> = Vec::new();
403            let mut kept_old_texts: Vec<String> = Vec::new();
404            // Track (old_text) already seen to handle same-old-text conflicts
405            let mut seen_old: std::collections::HashSet<String> = std::collections::HashSet::new();
406
407            for &idx in &sorted {
408                let er = &all_edits[idx];
409
410                // Exact duplicate: same old_text AND same new_text as a kept edit
411                let dominated = kept.iter().any(|&k| {
412                    let ek = &all_edits[k];
413                    ek.old_text == er.old_text && ek.new_text == er.new_text
414                });
415                if dominated {
416                    remove_set.insert((er.fix_idx, er.edit_idx));
417                    total_subsumed += 1;
418                    continue;
419                }
420
421                // Conflict: same old_text but different new_text — first wins
422                if seen_old.contains(&er.old_text) {
423                    remove_set.insert((er.fix_idx, er.edit_idx));
424                    total_subsumed += 1;
425                    continue;
426                }
427
428                // Subsumed: this edit's old_text is a substring of a kept edit's old_text
429                let subsumed = kept_old_texts
430                    .iter()
431                    .any(|kept_old| kept_old.contains(&er.old_text));
432                if subsumed {
433                    remove_set.insert((er.fix_idx, er.edit_idx));
434                    total_subsumed += 1;
435                    continue;
436                }
437
438                // Keep this edit
439                kept.push(idx);
440                kept_old_texts.push(er.old_text.clone());
441                seen_old.insert(er.old_text.clone());
442            }
443        }
444
445        // Remove subsumed edits from PlannedFixes (iterate in reverse to preserve indices)
446        if !remove_set.is_empty() {
447            for (fix_idx, fix) in fixes.iter_mut().enumerate() {
448                let mut edit_idx = fix.edits.len();
449                while edit_idx > 0 {
450                    edit_idx -= 1;
451                    if remove_set.contains(&(fix_idx, edit_idx)) {
452                        fix.edits.remove(edit_idx);
453                    }
454                }
455            }
456            // Remove PlannedFixes that have no remaining edits
457            fixes.retain(|f| !f.edits.is_empty());
458        }
459    }
460
461    plan.edits_subsumed = total_subsumed;
462}
463
464/// Consolidate LLM fix requests by component family when a family-level
465/// strategy exists. Multiple rules targeting the same `(file, family)` are
466/// merged into a single request with a unified message containing the target
467/// component structure and all incident variables.
468///
469/// Requests without a `family=` label, or whose family has no entry in
470/// `family_entries`, are left untouched.
471pub fn consolidate_family_requests(
472    requests: &mut Vec<LlmFixRequest>,
473    family_entries: &BTreeMap<String, FixStrategyEntry>,
474) {
475    use std::collections::BTreeSet;
476
477    if family_entries.is_empty() {
478        return;
479    }
480
481    // Extract family label from request labels (e.g., "family=Modal" -> "Modal").
482    fn extract_family(labels: &[String]) -> Option<String> {
483        labels
484            .iter()
485            .find(|l| l.starts_with("family="))
486            .and_then(|l| l.strip_prefix("family="))
487            .map(|s| s.to_string())
488    }
489
490    // Group indices by (file_path, family) where a family strategy exists.
491    let mut groups: BTreeMap<(PathBuf, String), Vec<usize>> = BTreeMap::new();
492    let mut ungrouped_indices: BTreeSet<usize> = BTreeSet::new();
493
494    for (idx, req) in requests.iter().enumerate() {
495        if let Some(family) = extract_family(&req.labels) {
496            let key = format!("family:{}", family);
497            if family_entries.contains_key(&key) {
498                groups
499                    .entry((req.file_path.clone(), family))
500                    .or_default()
501                    .push(idx);
502                continue;
503            }
504        }
505        ungrouped_indices.insert(idx);
506    }
507
508    if groups.is_empty() {
509        return;
510    }
511
512    // Build consolidated requests and collect indices to remove.
513    let mut consolidated: Vec<LlmFixRequest> = Vec::new();
514    let mut consumed_indices: BTreeSet<usize> = BTreeSet::new();
515
516    for ((file_path, family), indices) in &groups {
517        let key = format!("family:{}", family);
518        let entry = &family_entries[&key];
519
520        // Build the family migration context header.
521        let mut message = format!("## {} Family Migration\n", family);
522
523        if let Some(ref target) = entry.target_structure {
524            message.push_str(&format!(
525                "\nTarget structure (correct v6 composition):\n```jsx\n{}\n```\n",
526                target
527            ));
528        }
529        if !entry.retained_props.is_empty() {
530            message.push_str(&format!(
531                "\nProps that stay on <{}>: {}\n",
532                family,
533                entry.retained_props.join(", ")
534            ));
535        }
536        if !entry.prop_to_child.is_empty() {
537            message.push_str("\nProps that move to child components:\n");
538            for (prop, child) in &entry.prop_to_child {
539                message.push_str(&format!("  {} -> <{} />\n", prop, child));
540            }
541        }
542        if !entry.unmapped_removed_props.is_empty() {
543            message.push_str("\nRemoved props (move to child component as children or remove):\n");
544            for (prop, target) in &entry.unmapped_removed_props {
545                message.push_str(&format!("  {} -> {}\n", prop, target));
546            }
547        }
548        if !entry.child_props_to_parent.is_empty() {
549            message.push_str("\nChild props that move to parent:\n");
550            for (child_prop, parent_prop) in &entry.child_props_to_parent {
551                message.push_str(&format!("  {} -> {}\n", child_prop, parent_prop));
552            }
553        }
554        if !entry.removed_children.is_empty() {
555            message.push_str(&format!(
556                "\nRemoved children (no longer valid JSX): {}\n",
557                entry.removed_children.join(", ")
558            ));
559        }
560        if !entry.new_imports.is_empty() {
561            let src = entry.import_source.as_deref().unwrap_or("(same package)");
562            message.push_str(&format!(
563                "\nAdd imports: {} from '{}'\n",
564                entry.new_imports.join(", "),
565                src
566            ));
567        }
568        if !entry.removed_imports.is_empty() {
569            message.push_str(&format!(
570                "\nRemove imports (if no longer used): {}\n",
571                entry.removed_imports.join(", ")
572            ));
573        }
574
575        // Collect all incident data from the grouped requests.
576        let filtered_indices: Vec<usize> = {
577            let filtered: Vec<usize> = indices
578                .iter()
579                .copied()
580                .filter(|&idx| {
581                    let req = &requests[idx];
582                    let dominated = req.labels.iter().any(|l| {
583                        l == "change-type=signature-changed" || l == "change-type=type-changed"
584                    }) && (req.message.contains("base class changed")
585                        || req.message.contains("RefAttributes"));
586                    !dominated
587                })
588                .collect();
589            if filtered.is_empty() {
590                indices.clone()
591            } else {
592                filtered
593            }
594        };
595
596        message.push_str("\nIncidents found in this file:\n");
597        let mut all_lines: Vec<u32> = Vec::new();
598        let mut all_snips: Vec<(u32, String)> = Vec::new();
599        let mut all_labels: Vec<String> = Vec::new();
600        let mut seen_rules: BTreeSet<String> = BTreeSet::new();
601
602        for &idx in &filtered_indices {
603            let req = &requests[idx];
604            all_lines.push(req.line);
605
606            let rule_info = if seen_rules.insert(req.rule_id.clone()) {
607                format!("  Line {}: [{}]\n", req.line, req.rule_id)
608            } else {
609                format!("  Line {}: (same rule {})\n", req.line, req.rule_id)
610            };
611            message.push_str(&rule_info);
612
613            if let Some(var_start) = req.message.find("\n\nIncident context:") {
614                let var_section =
615                    if let Some(strat_start) = req.message[var_start..].find("\n\nFix strategy:") {
616                        &req.message[var_start..var_start + strat_start]
617                    } else {
618                        &req.message[var_start..]
619                    };
620                for line in var_section.trim().lines().skip(1) {
621                    message.push_str(&format!("    {}\n", line.trim()));
622                }
623            }
624
625            // Preserve per-incident fix strategy context (e.g., PropTypeChange
626            // from/to mappings) so the LLM knows exactly what rename or type
627            // change to apply alongside the family migration.
628            if let Some(strat_start) = req.message.find("\n\nFix strategy:") {
629                let strat_section = &req.message[strat_start..];
630                message.push_str("    Fix strategy:\n");
631                for line in strat_section.trim().lines().skip(1) {
632                    message.push_str(&format!("      {}\n", line.trim()));
633                }
634            }
635
636            if let Some(snip) = &req.code_snip {
637                all_snips.push((req.line, snip.clone()));
638            }
639            for label in &req.labels {
640                if !all_labels.contains(label) {
641                    all_labels.push(label.clone());
642                }
643            }
644        }
645
646        let first_line = all_lines.iter().copied().min().unwrap_or(0);
647        let first_uri = requests[indices[0]].file_uri.clone();
648
649        if !all_snips.is_empty() {
650            message.push_str("\nCode contexts:\n");
651            let mut seen_snips: BTreeSet<String> = BTreeSet::new();
652            for (line, snip) in &all_snips {
653                if seen_snips.insert(snip.clone()) {
654                    message.push_str(&format!("  (line {}):\n{}\n", line, snip));
655                }
656            }
657        }
658
659        consolidated.push(LlmFixRequest {
660            rule_id: format!("family:{}", family),
661            file_uri: first_uri,
662            file_path: file_path.clone(),
663            line: first_line,
664            message,
665            code_snip: None,
666            source: None,
667            labels: all_labels,
668        });
669
670        for &idx in indices {
671            consumed_indices.insert(idx);
672        }
673    }
674
675    // Rebuild the request list: ungrouped first, then consolidated.
676    let mut new_requests: Vec<LlmFixRequest> = Vec::new();
677    for (idx, req) in requests.drain(..).enumerate() {
678        if !consumed_indices.contains(&idx) {
679            new_requests.push(req);
680        }
681    }
682    new_requests.extend(consolidated);
683    *requests = new_requests;
684}
685
686/// Apply a fix plan to disk.
687///
688/// `lang` provides language-specific post-processing (e.g., import deduplication).
689/// After all files are written, calls `lang.post_apply()` for ecosystem-specific
690/// steps (e.g., `npm install` after `package.json` changes).
691pub fn apply_fixes(
692    plan: &FixPlan,
693    lang: &dyn LanguageFixProvider,
694    project_root: &Path,
695) -> Result<FixResult> {
696    // Capture baseline state before any edits (e.g., pre-existing unmet peer deps)
697    let pre_state = lang.pre_apply(project_root);
698
699    let mut result = FixResult {
700        edits_subsumed: plan.edits_subsumed,
701        ..FixResult::default()
702    };
703
704    for (file_path, fixes) in &plan.files {
705        let source = match std::fs::read_to_string(file_path) {
706            Ok(s) => s,
707            Err(e) => {
708                result
709                    .errors
710                    .push(format!("{}: {}", file_path.display(), e));
711                continue;
712            }
713        };
714
715        let mut lines: Vec<String> = source.lines().map(String::from).collect();
716        let mut any_changed = false;
717
718        let mut seen_edits: std::collections::HashSet<(u32, String, String)> =
719            std::collections::HashSet::new();
720
721        for fix in fixes {
722            for edit in &fix.edits {
723                let key = (edit.line, edit.old_text.clone(), edit.new_text.clone());
724                if !seen_edits.insert(key) {
725                    continue;
726                }
727                let idx = (edit.line as usize).saturating_sub(1);
728                if idx < lines.len() {
729                    let line = &lines[idx];
730                    if line.contains(&edit.old_text) {
731                        lines[idx] = if edit.replace_all {
732                            line.replace(&edit.old_text, &edit.new_text)
733                        } else {
734                            line.replacen(&edit.old_text, &edit.new_text, 1)
735                        };
736                        result.edits_applied += 1;
737                        any_changed = true;
738                    } else {
739                        tracing::debug!(
740                            file = %file_path.display(),
741                            line = edit.line,
742                            rule = %edit.rule_id,
743                            old_text = %edit.old_text,
744                            actual_line = %line,
745                            "Edit skipped: old_text not found on line"
746                        );
747                        result.edits_skipped += 1;
748                    }
749                } else {
750                    tracing::debug!(
751                        file = %file_path.display(),
752                        line = edit.line,
753                        rule = %edit.rule_id,
754                        old_text = %edit.old_text,
755                        total_lines = lines.len(),
756                        "Edit skipped: line index out of bounds"
757                    );
758                    result.edits_skipped += 1;
759                }
760            }
761        }
762
763        if any_changed {
764            lang.post_process_lines(&mut lines);
765            lines.retain(|_l| true);
766
767            let mut output = lines.join("\n");
768            if source.ends_with('\n') {
769                output.push('\n');
770            }
771            std::fs::write(file_path, output)?;
772            result.files_modified += 1;
773            result.modified_files.push(file_path.clone());
774        }
775    }
776
777    // Run post-apply hook (e.g., npm install after package.json changes)
778    if let Err(e) = lang.post_apply(project_root, &result.modified_files, pre_state) {
779        tracing::warn!("Post-apply hook failed: {}", e);
780        result.errors.push(format!("Post-apply hook failed: {}", e));
781    }
782
783    Ok(result)
784}
785
786/// Generate a unified diff preview of the planned changes.
787///
788/// `lang` provides language-specific post-processing (e.g., import deduplication).
789pub fn preview_fixes(plan: &FixPlan, lang: &dyn LanguageFixProvider) -> Result<String> {
790    let mut output = String::new();
791
792    for (file_path, fixes) in &plan.files {
793        let source = match std::fs::read_to_string(file_path) {
794            Ok(s) => s,
795            Err(_) => continue,
796        };
797
798        let lines: Vec<&str> = source.lines().collect();
799        let mut changed_lines: HashMap<usize, String> = HashMap::new();
800
801        for fix in fixes {
802            for edit in &fix.edits {
803                let idx = (edit.line as usize).saturating_sub(1);
804                if idx < lines.len() {
805                    let current = changed_lines
806                        .get(&idx)
807                        .map(String::as_str)
808                        .unwrap_or(lines[idx]);
809                    if current.contains(&edit.old_text) {
810                        let new_line = if edit.replace_all {
811                            current.replace(&edit.old_text, &edit.new_text)
812                        } else {
813                            current.replacen(&edit.old_text, &edit.new_text, 1)
814                        };
815                        changed_lines.insert(idx, new_line);
816                    }
817                }
818            }
819        }
820
821        if changed_lines.is_empty() {
822            continue;
823        }
824
825        for (_, line_content) in changed_lines.iter_mut() {
826            let mut single = [line_content.clone()];
827            lang.post_process_lines(&mut single);
828            *line_content = single.into_iter().next().unwrap();
829        }
830
831        output.push_str(&format!(
832            "--- a/{}\n+++ b/{}\n",
833            file_path.display(),
834            file_path.display()
835        ));
836
837        let mut changed_indices: Vec<usize> = changed_lines.keys().copied().collect();
838        changed_indices.sort();
839
840        for &idx in &changed_indices {
841            let context = 3;
842            let start = idx.saturating_sub(context);
843            let end = (idx + context + 1).min(lines.len());
844
845            output.push_str(&format!(
846                "@@ -{},{} +{},{} @@\n",
847                start + 1,
848                end - start,
849                start + 1,
850                end - start
851            ));
852
853            for (i, line) in lines.iter().enumerate().take(end).skip(start) {
854                if let Some(new_line) = changed_lines.get(&i) {
855                    output.push_str(&format!("-{}\n", line));
856                    output.push_str(&format!("+{}\n", new_line));
857                } else {
858                    output.push_str(&format!(" {}\n", line));
859                }
860            }
861        }
862    }
863
864    Ok(output)
865}
866
867// -- Pattern-based fix generators --
868
869fn plan_rename(
870    rule_id: &str,
871    incident: &Incident,
872    mappings: &[RenameMapping],
873    file_path: &PathBuf,
874    lang: &dyn LanguageFixProvider,
875) -> Option<PlannedFix> {
876    let line = incident.line_number?;
877
878    let matched_text = lang.get_matched_text_for_rename(incident, mappings);
879    let is_whole_file_rename = lang.is_whole_file_rename(incident);
880    let primary_mapping = mappings.iter().find(|m| m.old == matched_text);
881
882    let source = std::fs::read_to_string(file_path).ok()?;
883    let mut edits = Vec::new();
884
885    if is_whole_file_rename {
886        let mut sorted_mappings: Vec<&RenameMapping> =
887            mappings.iter().filter(|m| m.old != m.new).collect();
888        sorted_mappings.sort_by_key(|m| std::cmp::Reverse(m.old.len()));
889
890        for (idx, file_line) in source.lines().enumerate() {
891            let line_num = (idx + 1) as u32;
892            let mut consumed: Vec<&str> = Vec::new();
893            for m in &sorted_mappings {
894                if file_line.contains(m.old.as_str()) {
895                    let is_substring_of_consumed =
896                        consumed.iter().any(|c| c.contains(m.old.as_str()));
897                    if is_substring_of_consumed {
898                        continue;
899                    }
900                    edits.push(TextEdit {
901                        line: line_num,
902                        old_text: m.old.clone(),
903                        new_text: m.new.clone(),
904                        rule_id: rule_id.to_string(),
905                        description: format!("Rename '{}' to '{}'", m.old, m.new),
906                        replace_all: false,
907                    });
908                    consumed.push(&m.old);
909                }
910            }
911        }
912    } else if let Some(mapping) = primary_mapping {
913        if mapping.old == mapping.new {
914            return None;
915        }
916        edits.push(TextEdit {
917            line,
918            old_text: mapping.old.clone(),
919            new_text: mapping.new.clone(),
920            rule_id: rule_id.to_string(),
921            description: format!("Rename '{}' to '{}'", mapping.old, mapping.new),
922            replace_all: false,
923        });
924
925        let line_idx = (line as usize).saturating_sub(1);
926        let scan_start = line_idx.saturating_sub(3);
927        let scan_end = (line_idx + 5).min(source.lines().count());
928        for (idx, file_line) in source
929            .lines()
930            .enumerate()
931            .skip(scan_start)
932            .take(scan_end - scan_start)
933        {
934            let line_num = (idx + 1) as u32;
935            for m in mappings {
936                if m.old == m.new {
937                    continue;
938                }
939                if std::ptr::eq(m, mapping) && line_num == line {
940                    continue;
941                }
942                if file_line.contains(&m.old) {
943                    edits.push(TextEdit {
944                        line: line_num,
945                        old_text: m.old.clone(),
946                        new_text: m.new.clone(),
947                        rule_id: rule_id.to_string(),
948                        description: format!("Rename '{}' to '{}'", m.old, m.new),
949                        replace_all: false,
950                    });
951                }
952            }
953        }
954    } else if let Some(file_line) = source.lines().nth((line as usize).saturating_sub(1)) {
955        for m in mappings {
956            if m.old == m.new {
957                continue;
958            }
959            if file_line.contains(&m.old) {
960                edits.push(TextEdit {
961                    line,
962                    old_text: m.old.clone(),
963                    new_text: m.new.clone(),
964                    rule_id: rule_id.to_string(),
965                    description: format!("Rename '{}' to '{}'", m.old, m.new),
966                    replace_all: false,
967                });
968            }
969        }
970    }
971
972    if edits.is_empty() {
973        return None;
974    }
975
976    let desc = edits
977        .iter()
978        .map(|e| format!("'{}' -> '{}'", e.old_text, e.new_text))
979        .collect::<Vec<_>>()
980        .join(", ");
981
982    Some(PlannedFix {
983        edits,
984        confidence: FixConfidence::Exact,
985        source: FixSource::Pattern,
986        rule_id: rule_id.to_string(),
987        file_uri: incident.file_uri.clone(),
988        line,
989        description: format!("Rename {}", desc),
990    })
991}
992
993fn plan_import_path_change(
994    rule_id: &str,
995    incident: &Incident,
996    old_path: &str,
997    new_path: &str,
998    file_path: &PathBuf,
999) -> Option<PlannedFix> {
1000    let line = incident.line_number?;
1001
1002    let source = std::fs::read_to_string(file_path).ok()?;
1003    let lines: Vec<&str> = source.lines().collect();
1004    let idx = (line as usize).saturating_sub(1);
1005    let file_line = lines.get(idx)?;
1006
1007    // Determine which line contains the import path to rewrite.
1008    //
1009    // For single-line imports the incident line itself has the path:
1010    //   import { Chart } from '@patternfly/react-charts';
1011    //
1012    // For multi-line imports the incident fires on a specifier line while
1013    // the package path lives on the `from` line further down:
1014    //   import {
1015    //     Chart,          <-- incident line
1016    //     ChartAxis,
1017    //   } from '@patternfly/react-charts';   <-- old_path is here
1018    //
1019    // Strategy: if the incident line doesn't contain old_path, scan backwards
1020    // for `{` or `import` to confirm we're inside an import block, then scan
1021    // forward for the `from` clause that holds the package path.
1022    let target_line = if file_line.contains(old_path) {
1023        // Single-line import (or the incident already points at the from-line).
1024        // Guard against double-application: if new_path is already present
1025        // (e.g. old_path is a prefix of new_path), skip.
1026        if file_line.contains(new_path) {
1027            return None;
1028        }
1029        line
1030    } else {
1031        // Multi-line import: verify we are inside an import/export block
1032        // by scanning backwards (up to 50 lines) for `{` or `import`.
1033        let in_import_block = (0..idx).rev().take(50).any(|i| {
1034            let l = lines[i];
1035            l.contains('{')
1036                || l.trim_start().starts_with("import")
1037                || l.trim_start().starts_with("export")
1038        });
1039        if !in_import_block {
1040            return None;
1041        }
1042
1043        // Scan forward from the incident line for the `from` clause.
1044        let from_idx = (idx..lines.len()).take(50).find(|&i| {
1045            let trimmed = lines[i].trim_start();
1046            trimmed.starts_with("} from ")
1047                || trimmed.starts_with("from ")
1048                || (trimmed.contains(" from '") || trimmed.contains(" from \""))
1049        })?;
1050
1051        let from_line = lines[from_idx];
1052        if !from_line.contains(old_path) {
1053            return None; // Not our import
1054        }
1055        // Guard against double-application on the from-line.
1056        if from_line.contains(new_path) {
1057            return None;
1058        }
1059
1060        (from_idx + 1) as u32 // convert to 1-indexed
1061    };
1062
1063    Some(PlannedFix {
1064        edits: vec![TextEdit {
1065            line: target_line,
1066            old_text: old_path.to_string(),
1067            new_text: new_path.to_string(),
1068            rule_id: rule_id.to_string(),
1069            description: format!("Change import path '{}' -> '{}'", old_path, new_path),
1070            replace_all: false,
1071        }],
1072        confidence: FixConfidence::Exact,
1073        source: FixSource::Pattern,
1074        rule_id: rule_id.to_string(),
1075        file_uri: incident.file_uri.clone(),
1076        line: target_line,
1077        description: format!("Change import path to '{}'", new_path),
1078    })
1079}
1080
1081// -- Helpers --
1082
1083/// Convert a file:// URI to a filesystem path, relative to project root.
1084fn uri_to_path(uri: &str, project_root: &std::path::Path) -> PathBuf {
1085    let path_str = uri.strip_prefix("file://").unwrap_or(uri);
1086
1087    let path = PathBuf::from(path_str);
1088    if path.is_absolute() {
1089        path
1090    } else {
1091        project_root.join(path)
1092    }
1093}
1094
1095/// Try to infer a fix strategy from rule labels when no explicit mapping exists.
1096/// This is a fallback for rules not covered by any strategy file.
1097fn infer_strategy_from_labels(labels: &[String]) -> Option<&'static FixStrategy> {
1098    for label in labels {
1099        match label.as_str() {
1100            "change-type=prop-removal" => return Some(&FixStrategy::RemoveAttribute),
1101            "change-type=dom-structure"
1102            | "change-type=behavioral"
1103            | "change-type=accessibility"
1104            | "change-type=interface-removal"
1105            | "change-type=module-export"
1106            | "change-type=other" => return Some(&FixStrategy::Manual),
1107            _ => {}
1108        }
1109    }
1110    None
1111}
1112
1113#[cfg(test)]
1114mod tests {
1115    use super::*;
1116
1117    // -- uri_to_path tests --
1118
1119    #[test]
1120    fn test_uri_to_path_absolute() {
1121        let path = uri_to_path(
1122            "file:///home/user/project/src/App.tsx",
1123            std::path::Path::new("/ignored"),
1124        );
1125        assert_eq!(path, PathBuf::from("/home/user/project/src/App.tsx"));
1126    }
1127
1128    #[test]
1129    fn test_uri_to_path_relative() {
1130        let path = uri_to_path("src/App.tsx", std::path::Path::new("/home/user/project"));
1131        assert_eq!(path, PathBuf::from("/home/user/project/src/App.tsx"));
1132    }
1133
1134    #[test]
1135    fn test_uri_to_path_no_file_prefix() {
1136        let path = uri_to_path("/absolute/path.tsx", std::path::Path::new("/root"));
1137        assert_eq!(path, PathBuf::from("/absolute/path.tsx"));
1138    }
1139
1140    // -- infer_strategy_from_labels tests --
1141
1142    #[test]
1143    fn test_infer_prop_removal() {
1144        let labels = vec!["change-type=prop-removal".to_string()];
1145        let strategy = infer_strategy_from_labels(&labels);
1146        assert!(matches!(strategy, Some(&FixStrategy::RemoveAttribute)));
1147    }
1148
1149    #[test]
1150    fn test_infer_dom_structure_manual() {
1151        let labels = vec!["change-type=dom-structure".to_string()];
1152        let strategy = infer_strategy_from_labels(&labels);
1153        assert!(matches!(strategy, Some(&FixStrategy::Manual)));
1154    }
1155
1156    #[test]
1157    fn test_infer_unknown_label_returns_none() {
1158        let labels = vec!["change-type=rename".to_string()];
1159        let strategy = infer_strategy_from_labels(&labels);
1160        assert!(strategy.is_none());
1161    }
1162
1163    #[test]
1164    fn test_infer_empty_labels_returns_none() {
1165        let labels: Vec<String> = Vec::new();
1166        let strategy = infer_strategy_from_labels(&labels);
1167        assert!(strategy.is_none());
1168    }
1169
1170    #[test]
1171    fn test_infer_first_matching_label_wins() {
1172        let labels = vec![
1173            "framework=patternfly".to_string(),
1174            "change-type=prop-removal".to_string(),
1175            "change-type=dom-structure".to_string(),
1176        ];
1177        let strategy = infer_strategy_from_labels(&labels);
1178        assert!(matches!(strategy, Some(&FixStrategy::RemoveAttribute)));
1179    }
1180
1181    // -- consolidate_family_requests tests --
1182
1183    fn make_llm_request(rule_id: &str, file: &str, line: u32, labels: Vec<&str>) -> LlmFixRequest {
1184        LlmFixRequest {
1185            rule_id: rule_id.to_string(),
1186            file_uri: format!("file://{}", file),
1187            file_path: PathBuf::from(file),
1188            line,
1189            message: format!("Rule {} triggered", rule_id),
1190            code_snip: Some(format!("// line {}", line)),
1191            source: None,
1192            labels: labels.into_iter().map(|s| s.to_string()).collect(),
1193        }
1194    }
1195
1196    fn make_family_entry(target_structure: &str, new_imports: Vec<&str>) -> FixStrategyEntry {
1197        FixStrategyEntry {
1198            strategy: "FamilyMigration".to_string(),
1199            target_structure: Some(target_structure.to_string()),
1200            new_imports: new_imports.into_iter().map(|s| s.to_string()).collect(),
1201            import_source: Some("@patternfly/react-core".to_string()),
1202            ..Default::default()
1203        }
1204    }
1205
1206    #[test]
1207    fn test_consolidate_single_family_request() {
1208        let mut requests = vec![make_llm_request(
1209            "mastheadbrand-signature-changed",
1210            "/src/viewLayout.tsx",
1211            16,
1212            vec!["family=Masthead", "change-type=prop-type-changed"],
1213        )];
1214
1215        let mut families = BTreeMap::new();
1216        families.insert(
1217            "family:Masthead".to_string(),
1218            make_family_entry(
1219                "<Masthead>\n  <MastheadMain>\n    <MastheadBrand>\n      <MastheadLogo />\n    </MastheadBrand>\n  </MastheadMain>\n</Masthead>",
1220                vec!["MastheadLogo", "MastheadMain"],
1221            ),
1222        );
1223
1224        consolidate_family_requests(&mut requests, &families);
1225
1226        assert_eq!(requests.len(), 1);
1227        assert_eq!(requests[0].rule_id, "family:Masthead");
1228        assert!(requests[0].message.contains("Masthead Family Migration"));
1229    }
1230
1231    #[test]
1232    fn test_consolidate_does_not_drop_all_incidents() {
1233        let mut requests = vec![{
1234            let mut req = make_llm_request(
1235                "mastheadbrand-signature-changed",
1236                "/src/viewLayout.tsx",
1237                16,
1238                vec!["family=Masthead", "change-type=signature-changed"],
1239            );
1240            req.message =
1241                "Interface 'MastheadBrandProps' base class changed from anchor to div".to_string();
1242            req
1243        }];
1244
1245        let mut families = BTreeMap::new();
1246        families.insert(
1247            "family:Masthead".to_string(),
1248            make_family_entry("<Masthead>\n  <MastheadBrand>\n    <MastheadLogo />\n  </MastheadBrand>\n</Masthead>", vec![]),
1249        );
1250
1251        consolidate_family_requests(&mut requests, &families);
1252
1253        assert_eq!(requests.len(), 1);
1254        assert_eq!(requests[0].rule_id, "family:Masthead");
1255        assert!(requests[0]
1256            .message
1257            .contains("mastheadbrand-signature-changed"),);
1258    }
1259
1260    // -- plan_import_path_change tests --
1261
1262    fn make_incident(file_uri: &str, line: u32) -> Incident {
1263        Incident {
1264            file_uri: file_uri.to_string(),
1265            line_number: Some(line),
1266            code_location: None,
1267            message: String::new(),
1268            code_snip: None,
1269            variables: Default::default(),
1270            effort: None,
1271            links: Vec::new(),
1272            is_dependency_incident: false,
1273        }
1274    }
1275
1276    #[test]
1277    fn test_import_path_change_single_line() {
1278        let dir = tempfile::tempdir().unwrap();
1279        let file = dir.path().join("single.tsx");
1280        std::fs::write(&file, "import { Chart } from '@patternfly/react-charts';\n").unwrap();
1281
1282        let incident = make_incident("file://single.tsx", 1);
1283        let fix = plan_import_path_change(
1284            "test-rule",
1285            &incident,
1286            "@patternfly/react-charts",
1287            "@patternfly/react-charts/victory",
1288            &file,
1289        );
1290
1291        let fix = fix.expect("should produce a fix for single-line import");
1292        assert_eq!(fix.edits.len(), 1);
1293        assert_eq!(fix.edits[0].line, 1);
1294        assert_eq!(fix.edits[0].old_text, "@patternfly/react-charts");
1295        assert_eq!(fix.edits[0].new_text, "@patternfly/react-charts/victory");
1296    }
1297
1298    #[test]
1299    fn test_import_path_change_multiline_specifier() {
1300        let dir = tempfile::tempdir().unwrap();
1301        let file = dir.path().join("multi.tsx");
1302        std::fs::write(
1303            &file,
1304            "\
1305import {
1306  Chart,
1307  ChartAxis,
1308  ChartBar,
1309} from '@patternfly/react-charts';
1310import { Button } from '@patternfly/react-core';
1311",
1312        )
1313        .unwrap();
1314
1315        // Incident fires on line 2 (Chart specifier), but the from-clause
1316        // is on line 5.
1317        let incident = make_incident("file://multi.tsx", 2);
1318        let fix = plan_import_path_change(
1319            "test-rule",
1320            &incident,
1321            "@patternfly/react-charts",
1322            "@patternfly/react-charts/victory",
1323            &file,
1324        );
1325
1326        let fix = fix.expect("should produce a fix for multi-line import");
1327        assert_eq!(fix.edits.len(), 1);
1328        assert_eq!(
1329            fix.edits[0].line, 5,
1330            "edit should target the from-clause line"
1331        );
1332        assert_eq!(fix.edits[0].old_text, "@patternfly/react-charts");
1333        assert_eq!(fix.edits[0].new_text, "@patternfly/react-charts/victory");
1334    }
1335
1336    #[test]
1337    fn test_import_path_change_multiline_different_specifier() {
1338        let dir = tempfile::tempdir().unwrap();
1339        let file = dir.path().join("multi2.tsx");
1340        std::fs::write(
1341            &file,
1342            "\
1343import {
1344  Chart,
1345  ChartAxis,
1346  ChartBar,
1347} from '@patternfly/react-charts';
1348",
1349        )
1350        .unwrap();
1351
1352        // Incident on line 4 (ChartBar specifier) should also resolve to line 5.
1353        let incident = make_incident("file://multi2.tsx", 4);
1354        let fix = plan_import_path_change(
1355            "test-rule",
1356            &incident,
1357            "@patternfly/react-charts",
1358            "@patternfly/react-charts/victory",
1359            &file,
1360        );
1361
1362        let fix = fix.expect("should produce a fix for any specifier in multi-line import");
1363        assert_eq!(fix.edits[0].line, 5);
1364    }
1365
1366    #[test]
1367    fn test_import_path_change_already_migrated() {
1368        let dir = tempfile::tempdir().unwrap();
1369        let file = dir.path().join("migrated.tsx");
1370        std::fs::write(
1371            &file,
1372            "import { Chart } from '@patternfly/react-charts/victory';\n",
1373        )
1374        .unwrap();
1375
1376        let incident = make_incident("file://migrated.tsx", 1);
1377        let fix = plan_import_path_change(
1378            "test-rule",
1379            &incident,
1380            "@patternfly/react-charts",
1381            "@patternfly/react-charts/victory",
1382            &file,
1383        );
1384
1385        assert!(fix.is_none(), "should skip already-migrated imports");
1386    }
1387
1388    #[test]
1389    fn test_import_path_change_multiline_already_migrated() {
1390        let dir = tempfile::tempdir().unwrap();
1391        let file = dir.path().join("migrated_multi.tsx");
1392        std::fs::write(
1393            &file,
1394            "\
1395import {
1396  Chart,
1397  ChartAxis,
1398} from '@patternfly/react-charts/victory';
1399",
1400        )
1401        .unwrap();
1402
1403        let incident = make_incident("file://migrated_multi.tsx", 2);
1404        let fix = plan_import_path_change(
1405            "test-rule",
1406            &incident,
1407            "@patternfly/react-charts",
1408            "@patternfly/react-charts/victory",
1409            &file,
1410        );
1411
1412        assert!(
1413            fix.is_none(),
1414            "should skip already-migrated multi-line imports"
1415        );
1416    }
1417
1418    #[test]
1419    fn test_import_path_change_non_import_context() {
1420        let dir = tempfile::tempdir().unwrap();
1421        let file = dir.path().join("jsx.tsx");
1422        std::fs::write(
1423            &file,
1424            "\
1425import { Chart } from '@patternfly/react-charts';
1426
1427function App() {
1428  return <Chart />;
1429}
1430",
1431        )
1432        .unwrap();
1433
1434        // Incident on line 4 (JSX usage), no import block above within scope.
1435        let incident = make_incident("file://jsx.tsx", 4);
1436        let fix = plan_import_path_change(
1437            "test-rule",
1438            &incident,
1439            "@patternfly/react-charts",
1440            "@patternfly/react-charts/victory",
1441            &file,
1442        );
1443
1444        // The backward scan WILL find `import` on line 1 and `{` on line 3
1445        // (the function body). The forward scan from line 4 won't find a
1446        // `from` clause with the old path, so it should return None or
1447        // find the wrong thing. Either way, the from-line won't contain
1448        // old_path so it should safely return None.
1449        // Actually, the backward scan finds `{` on line 3, confirming
1450        // "import block". Then forward scan looks for `from`, which doesn't
1451        // appear. So find returns None, and the `?` propagates.
1452        assert!(
1453            fix.is_none(),
1454            "should not produce a fix for non-import JSX usage"
1455        );
1456    }
1457
1458    #[test]
1459    fn test_import_path_change_export_reexport() {
1460        let dir = tempfile::tempdir().unwrap();
1461        let file = dir.path().join("reexport.tsx");
1462        std::fs::write(
1463            &file,
1464            "\
1465export {
1466  Chart,
1467  ChartAxis,
1468} from '@patternfly/react-charts';
1469",
1470        )
1471        .unwrap();
1472
1473        let incident = make_incident("file://reexport.tsx", 2);
1474        let fix = plan_import_path_change(
1475            "test-rule",
1476            &incident,
1477            "@patternfly/react-charts",
1478            "@patternfly/react-charts/victory",
1479            &file,
1480        );
1481
1482        let fix = fix.expect("should handle export re-exports");
1483        assert_eq!(fix.edits[0].line, 4);
1484        assert_eq!(fix.edits[0].old_text, "@patternfly/react-charts");
1485    }
1486
1487    #[test]
1488    fn test_consolidate_preserves_fix_strategy_context() {
1489        let mut requests = vec![{
1490            let mut req = make_llm_request(
1491                "menutoggle-splitbuttonoptions-changed",
1492                "/src/ToolbarBulkSelector.tsx",
1493                134,
1494                vec!["family=MenuToggle", "change-type=prop-type-changed"],
1495            );
1496            // Simulate the enriched message with both incident context and fix strategy
1497            req.message = "property splitButtonOptions was replaced by splitButtonItems\n\n\
1498                Incident context:\n  componentName: MenuToggle\n  propName: splitButtonOptions\n\n\
1499                Fix strategy:\nStrategy: PropTypeChange\nComponent: MenuToggle\n\
1500                Prop: splitButtonOptions\nFrom: property: splitButtonOptions: SplitButtonOptions\n\
1501                To: property: splitButtonItems: ReactNode[]"
1502                .to_string();
1503            req
1504        }];
1505
1506        let mut families = BTreeMap::new();
1507        families.insert(
1508            "family:MenuToggle".to_string(),
1509            make_family_entry(
1510                "<MenuToggle splitButtonItems={...} />",
1511                vec!["MenuToggleAction"],
1512            ),
1513        );
1514
1515        consolidate_family_requests(&mut requests, &families);
1516
1517        assert_eq!(requests.len(), 1);
1518        assert_eq!(requests[0].rule_id, "family:MenuToggle");
1519        // Verify incident context variables are preserved
1520        assert!(
1521            requests[0].message.contains("componentName: MenuToggle"),
1522            "incident context variables should be preserved"
1523        );
1524        // Verify fix strategy context is preserved (this was previously dropped)
1525        assert!(
1526            requests[0].message.contains("Strategy: PropTypeChange"),
1527            "fix strategy type should be preserved"
1528        );
1529        assert!(
1530            requests[0]
1531                .message
1532                .contains("From: property: splitButtonOptions: SplitButtonOptions"),
1533            "fix strategy 'from' should be preserved"
1534        );
1535        assert!(
1536            requests[0]
1537                .message
1538                .contains("To: property: splitButtonItems: ReactNode[]"),
1539            "fix strategy 'to' should be preserved"
1540        );
1541    }
1542}