1use 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
21pub 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 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 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 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 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 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 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 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 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 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 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, labels: violation.labels.clone(),
206 });
207 }
208 }
209 }
210 }
211 }
212
213 merge_dependency_inserts(&mut plan);
219
220 for fixes in plan.files.values_mut() {
222 fixes.sort_by_key(|f| std::cmp::Reverse(f.line));
223 }
224
225 deduplicate_edits(&mut plan);
231
232 Ok(plan)
233}
234
235fn merge_dependency_inserts(plan: &mut FixPlan) {
251 for (file_path, fixes) in plan.files.iter_mut() {
252 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 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 ((_line, old_text), indices) in &insert_groups {
283 if indices.len() <= 1 {
284 continue;
285 }
286
287 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 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 let mut seen = std::collections::HashSet::new();
313 new_entries.retain(|e| seen.insert(e.clone()));
314
315 let merged_new_text = format!("{}\n{}", new_entries.join(",\n"), old_text);
318
319 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 for &(fix_idx, edit_idx) in &indices[1..] {
327 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
341fn deduplicate_edits(plan: &mut FixPlan) {
345 let mut total_subsumed: usize = 0;
346
347 for fixes in plan.files.values_mut() {
348 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 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 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 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 let mut kept: Vec<usize> = Vec::new();
403 let mut kept_old_texts: Vec<String> = Vec::new();
404 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 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 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 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 kept.push(idx);
440 kept_old_texts.push(er.old_text.clone());
441 seen_old.insert(er.old_text.clone());
442 }
443 }
444
445 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 fixes.retain(|f| !f.edits.is_empty());
458 }
459 }
460
461 plan.edits_subsumed = total_subsumed;
462}
463
464pub 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 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 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 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 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 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 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 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
686pub fn apply_fixes(
692 plan: &FixPlan,
693 lang: &dyn LanguageFixProvider,
694 project_root: &Path,
695) -> Result<FixResult> {
696 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 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
786pub 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
867fn 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 let target_line = if file_line.contains(old_path) {
1023 if file_line.contains(new_path) {
1027 return None;
1028 }
1029 line
1030 } else {
1031 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 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; }
1055 if from_line.contains(new_path) {
1057 return None;
1058 }
1059
1060 (from_idx + 1) as u32 };
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
1081fn 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
1095fn 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 #[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 #[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 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 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 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 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 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 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 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 assert!(
1521 requests[0].message.contains("componentName: MenuToggle"),
1522 "incident context variables should be preserved"
1523 );
1524 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}