1use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10pub type StrategiesAndFamilies = (
12 BTreeMap<String, FixStrategy>,
13 BTreeMap<String, FixStrategyEntry>,
14);
15
16pub use konveyor_core::fix::{
18 FixConfidence, FixSource, FixStrategyEntry, MappingEntry as StrategyMappingEntry,
19 MemberMappingEntry,
20};
21
22pub use konveyor_core::incident;
24pub use konveyor_core::report;
25pub use konveyor_core::rule;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TextEdit {
30 pub line: u32,
32 pub old_text: String,
34 pub new_text: String,
36 pub rule_id: String,
38 pub description: String,
40 #[serde(default)]
44 pub replace_all: bool,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct PlannedFix {
50 pub edits: Vec<TextEdit>,
52 pub confidence: FixConfidence,
54 pub source: FixSource,
56 pub rule_id: String,
58 pub file_uri: String,
60 pub line: u32,
62 pub description: String,
64}
65
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct FixPlan {
69 pub files: BTreeMap<PathBuf, Vec<PlannedFix>>,
71 pub manual: Vec<ManualFixItem>,
73 pub pending_llm: Vec<LlmFixRequest>,
75 #[serde(default)]
78 pub edits_subsumed: usize,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct ManualFixItem {
84 pub rule_id: String,
85 pub file_uri: String,
86 pub line: u32,
87 pub message: String,
88 pub code_snip: Option<String>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct LlmFixRequest {
94 pub rule_id: String,
95 pub file_uri: String,
96 pub file_path: PathBuf,
97 pub line: u32,
98 pub message: String,
99 pub code_snip: Option<String>,
100 pub source: Option<String>,
102 #[serde(default, skip_serializing_if = "Vec::is_empty")]
105 pub labels: Vec<String>,
106}
107
108#[derive(Debug, Default)]
110pub struct FixResult {
111 pub files_modified: usize,
113 pub edits_applied: usize,
115 pub edits_skipped: usize,
117 pub edits_subsumed: usize,
119 pub errors: Vec<String>,
121 pub modified_files: Vec<std::path::PathBuf>,
123}
124
125#[derive(Debug, Clone)]
128pub struct RenameMapping {
129 pub old: String,
130 pub new: String,
131}
132
133#[derive(Debug, Clone)]
136pub enum FixStrategy {
137 Rename(Vec<RenameMapping>),
140 RemoveAttribute,
143 ImportPathChange { old_path: String, new_path: String },
145 CssVariablePrefix {
147 old_prefix: String,
148 new_prefix: String,
149 exclude_patterns: Vec<String>,
151 },
152 EnsureDependency {
155 package: String,
156 new_version: String,
157 },
158 Manual,
160 Llm { context: Option<String> },
165}
166
167pub fn strategy_entry_to_fix_strategy(entry: &FixStrategyEntry) -> FixStrategy {
173 match entry.strategy.as_str() {
174 "Rename" => {
175 let mut renames: Vec<RenameMapping> = Vec::new();
176 for m in &entry.mappings {
178 if let (Some(from), Some(to)) = (&m.from, &m.to) {
179 renames.push(RenameMapping {
180 old: from.clone(),
181 new: to.clone(),
182 });
183 }
184 }
185 if renames.is_empty() {
187 if let (Some(from), Some(to)) = (&entry.from, &entry.to) {
188 renames.push(RenameMapping {
189 old: from.clone(),
190 new: to.clone(),
191 });
192 }
193 }
194 if renames.is_empty() {
195 FixStrategy::Manual
196 } else {
197 FixStrategy::Rename(renames)
198 }
199 }
200 "RemoveProp" => FixStrategy::RemoveAttribute,
201 "CssVariablePrefix" => {
202 if let (Some(from), Some(to)) = (&entry.from, &entry.to) {
203 FixStrategy::CssVariablePrefix {
204 old_prefix: from.clone(),
205 new_prefix: to.clone(),
206 exclude_patterns: entry.exclude_patterns.clone(),
207 }
208 } else {
209 FixStrategy::Manual
210 }
211 }
212 "ImportPathChange" => {
213 if let (Some(from), Some(to)) = (&entry.from, &entry.to) {
214 FixStrategy::ImportPathChange {
215 old_path: from.clone(),
216 new_path: to.clone(),
217 }
218 } else {
219 FixStrategy::Manual
220 }
221 }
222 "EnsureDependency" => {
223 if let (Some(package), Some(new_version)) = (&entry.package, &entry.new_version) {
224 FixStrategy::EnsureDependency {
225 package: package.clone(),
226 new_version: new_version.clone(),
227 }
228 } else {
229 FixStrategy::Manual
230 }
231 }
232 "PropValueChange" | "PropTypeChange" => FixStrategy::Llm {
233 context: Some(format_strategy_context(entry)),
234 },
235 "LlmAssisted" => FixStrategy::Llm {
236 context: Some(format_strategy_context(entry)),
237 },
238 "ChildToProp"
241 | "PropToChild"
242 | "PropToChildren"
243 | "CompositionChange"
244 | "DeprecatedMigration" => FixStrategy::Llm {
245 context: Some(format_strategy_context(entry)),
246 },
247 "FamilyMigration" => FixStrategy::Llm {
250 context: Some(format_family_migration_context(entry)),
251 },
252 _ => FixStrategy::Manual,
253 }
254}
255
256fn format_strategy_context(entry: &FixStrategyEntry) -> String {
260 let mut parts = Vec::new();
261 parts.push(format!("Strategy: {}", entry.strategy));
262 if let Some(ref c) = entry.component {
263 parts.push(format!("Component: {}", c));
264 }
265 if let Some(ref p) = entry.prop {
266 parts.push(format!("Prop: {}", p));
267 }
268 if let Some(ref from) = entry.from {
269 parts.push(format!("From: {}", from));
270 }
271 if let Some(ref to) = entry.to {
272 parts.push(format!("To: {}", to));
273 }
274 if let Some(ref repl) = entry.replacement {
275 parts.push(format!("Replacement: {}", repl));
276 }
277 if !entry.mappings.is_empty() {
278 let maps: Vec<String> = entry
279 .mappings
280 .iter()
281 .filter_map(|m| match (&m.from, &m.to) {
282 (Some(f), Some(t)) => Some(format!(" {} -> {}", f, t)),
283 _ => None,
284 })
285 .collect();
286 if !maps.is_empty() {
287 parts.push(format!("Mappings:\n{}", maps.join("\n")));
288 }
289 }
290 if !entry.member_mappings.is_empty() {
291 let maps: Vec<String> = entry
292 .member_mappings
293 .iter()
294 .map(|m| format!(" {} -> {}", m.old_name, m.new_name))
295 .collect();
296 parts.push(format!("Member mappings:\n{}", maps.join("\n")));
297 }
298 if !entry.removed_members.is_empty() {
299 parts.push(format!(
300 "Removed members: {}",
301 entry.removed_members.join(", ")
302 ));
303 }
304 parts.join("\n")
305}
306
307fn format_family_migration_context(entry: &FixStrategyEntry) -> String {
310 let mut parts = Vec::new();
311 parts.push("Strategy: FamilyMigration".to_string());
312
313 if let Some(ref target) = entry.target_structure {
314 parts.push(format!(
315 "\nTarget structure (correct v6 composition):\n```jsx\n{}\n```",
316 target
317 ));
318 }
319 if let Some(ref comp) = entry.component {
320 parts.push(format!("Component: {}", comp));
321 }
322 if !entry.retained_props.is_empty() {
323 parts.push(format!(
324 "Props that stay on root: {}",
325 entry.retained_props.join(", ")
326 ));
327 }
328 if !entry.prop_to_child.is_empty() {
329 let maps: Vec<String> = entry
330 .prop_to_child
331 .iter()
332 .map(|(prop, child)| format!(" {} -> <{} />", prop, child))
333 .collect();
334 parts.push(format!(
335 "Props that move to child components:\n{}",
336 maps.join("\n")
337 ));
338 }
339 if !entry.child_props_to_parent.is_empty() {
340 let maps: Vec<String> = entry
341 .child_props_to_parent
342 .iter()
343 .map(|(child_prop, parent_prop)| format!(" {} -> {}", child_prop, parent_prop))
344 .collect();
345 parts.push(format!(
346 "Child props that move to parent:\n{}",
347 maps.join("\n")
348 ));
349 }
350 if !entry.removed_children.is_empty() {
351 parts.push(format!(
352 "Removed children: {}",
353 entry.removed_children.join(", ")
354 ));
355 }
356 if !entry.new_imports.is_empty() {
357 let src = entry.import_source.as_deref().unwrap_or("(same package)");
358 parts.push(format!(
359 "Add imports: {} from '{}'",
360 entry.new_imports.join(", "),
361 src
362 ));
363 }
364 if !entry.removed_imports.is_empty() {
365 parts.push(format!(
366 "Remove imports: {}",
367 entry.removed_imports.join(", ")
368 ));
369 }
370 if !entry.prop_value_changes.is_empty() {
371 let mut lines = Vec::new();
372 for (prop, mappings) in &entry.prop_value_changes {
373 for m in mappings {
374 if let (Some(from), Some(to)) = (&m.from, &m.to) {
375 lines.push(format!(" {}: {} -> {}", prop, from, to));
376 }
377 }
378 }
379 if !lines.is_empty() {
380 parts.push(format!("Prop value changes:\n{}", lines.join("\n")));
381 }
382 }
383
384 if let Some(ref dm) = entry.deprecated_migration {
386 parts.push(format!(
387 "\nDeprecated -> v6 migration:\n Old import: {}\n New import: {}",
388 dm.old_package, dm.new_package
389 ));
390
391 if !dm.matching_props.is_empty() {
393 let mut lines = Vec::new();
394 for p in &dm.matching_props {
395 if p.type_changed {
396 lines.push(format!(
397 " {} -> {} (TYPE CHANGED):\n old: {}\n new: {}",
398 p.old_name,
399 p.new_name,
400 p.old_type.as_deref().unwrap_or("?"),
401 p.new_type.as_deref().unwrap_or("?")
402 ));
403 } else {
404 let typ = p.new_type.as_deref().unwrap_or("?");
405 if p.old_name == p.new_name {
406 lines.push(format!(" {}: {} (unchanged)", p.new_name, typ));
407 } else {
408 lines.push(format!(
409 " {} -> {}: {} (renamed, type unchanged)",
410 p.old_name, p.new_name, typ
411 ));
412 }
413 }
414 }
415 parts.push(format!("Matching props:\n{}", lines.join("\n")));
416 }
417
418 if !dm.new_props.is_empty() {
420 let mut lines = Vec::new();
421 for (name, typ) in &dm.new_props {
422 let mut line = format!(" {}: {}", name, typ);
423 if (typ.contains("=> React.ReactNode")
425 || typ.contains("=> ReactNode")
426 || typ.contains("=> React.ReactElement"))
427 && typ.contains("=>")
428 {
429 line.push_str(
430 "\n NOTE: This is a render function. Pass a function REFERENCE, \
431 not a function call.",
432 );
433 }
434 lines.push(line);
435 }
436 parts.push(format!(
437 "New props on v6 (not on deprecated version):\n{}",
438 lines.join("\n")
439 ));
440 }
441
442 if !dm.removed_props.is_empty() {
444 parts.push(format!(
445 "Removed props (no v6 equivalent): {}",
446 dm.removed_props.join(", ")
447 ));
448 }
449 }
450
451 parts.join("\n")
452}
453
454pub fn load_strategies_and_families(
459 path: &Path,
460) -> Result<StrategiesAndFamilies, Box<dyn std::error::Error>> {
461 let content = std::fs::read_to_string(path)?;
462 let entries: BTreeMap<String, FixStrategyEntry> = serde_json::from_str(&content)?;
463 let strategies = entries
464 .iter()
465 .map(|(rule_id, entry)| (rule_id.clone(), strategy_entry_to_fix_strategy(entry)))
466 .collect();
467 let families = entries
468 .into_iter()
469 .filter(|(k, _)| k.starts_with("family:"))
470 .collect();
471 Ok((strategies, families))
472}
473
474pub fn load_strategies_from_json(
478 path: &Path,
479) -> Result<BTreeMap<String, FixStrategy>, Box<dyn std::error::Error>> {
480 let content = std::fs::read_to_string(path)?;
481 let entries: BTreeMap<String, FixStrategyEntry> = serde_json::from_str(&content)?;
482 let strategies = entries
483 .iter()
484 .map(|(rule_id, entry)| (rule_id.clone(), strategy_entry_to_fix_strategy(entry)))
485 .collect();
486 Ok(strategies)
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492
493 fn make_strategy_entry(strategy: &str) -> FixStrategyEntry {
494 FixStrategyEntry::new(strategy)
495 }
496
497 #[test]
498 fn test_rename_with_top_level_from_to() {
499 let mut entry = make_strategy_entry("Rename");
500 entry.from = Some("Chip".to_string());
501 entry.to = Some("Label".to_string());
502
503 match strategy_entry_to_fix_strategy(&entry) {
504 FixStrategy::Rename(mappings) => {
505 assert_eq!(mappings.len(), 1);
506 assert_eq!(mappings[0].old, "Chip");
507 assert_eq!(mappings[0].new, "Label");
508 }
509 other => panic!("Expected Rename, got {:?}", other),
510 }
511 }
512
513 #[test]
514 fn test_rename_with_mappings_array() {
515 let mut entry = make_strategy_entry("Rename");
516 entry.mappings = vec![
517 StrategyMappingEntry {
518 from: Some("Chip".to_string()),
519 to: Some("Label".to_string()),
520 component: None,
521 prop: None,
522 },
523 StrategyMappingEntry {
524 from: Some("ChipGroup".to_string()),
525 to: Some("LabelGroup".to_string()),
526 component: None,
527 prop: None,
528 },
529 ];
530
531 match strategy_entry_to_fix_strategy(&entry) {
532 FixStrategy::Rename(mappings) => {
533 assert_eq!(mappings.len(), 2);
534 assert_eq!(mappings[0].old, "Chip");
535 assert_eq!(mappings[0].new, "Label");
536 assert_eq!(mappings[1].old, "ChipGroup");
537 assert_eq!(mappings[1].new, "LabelGroup");
538 }
539 other => panic!("Expected Rename, got {:?}", other),
540 }
541 }
542
543 #[test]
544 fn test_rename_mappings_take_precedence_over_top_level() {
545 let mut entry = make_strategy_entry("Rename");
546 entry.from = Some("TopLevel".to_string());
547 entry.to = Some("ShouldBeIgnored".to_string());
548 entry.mappings = vec![StrategyMappingEntry {
549 from: Some("FromMapping".to_string()),
550 to: Some("ToMapping".to_string()),
551 component: None,
552 prop: None,
553 }];
554
555 match strategy_entry_to_fix_strategy(&entry) {
556 FixStrategy::Rename(mappings) => {
557 assert_eq!(mappings.len(), 1);
558 assert_eq!(mappings[0].old, "FromMapping");
559 }
560 other => panic!("Expected Rename, got {:?}", other),
561 }
562 }
563
564 #[test]
565 fn test_css_variable_prefix() {
566 let mut entry = make_strategy_entry("CssVariablePrefix");
567 entry.from = Some("pf-v5-".to_string());
568 entry.to = Some("pf-v6-".to_string());
569
570 match strategy_entry_to_fix_strategy(&entry) {
571 FixStrategy::CssVariablePrefix {
572 old_prefix,
573 new_prefix,
574 exclude_patterns,
575 } => {
576 assert_eq!(old_prefix, "pf-v5-");
577 assert_eq!(new_prefix, "pf-v6-");
578 assert!(exclude_patterns.is_empty());
579 }
580 other => panic!("Expected CssVariablePrefix, got {:?}", other),
581 }
582 }
583
584 #[test]
585 fn test_css_variable_prefix_missing_fields_falls_to_manual() {
586 let entry = make_strategy_entry("CssVariablePrefix");
587 match strategy_entry_to_fix_strategy(&entry) {
588 FixStrategy::Manual => {}
589 other => panic!("Expected Manual, got {:?}", other),
590 }
591 }
592
593 #[test]
594 fn test_import_path_change() {
595 let mut entry = make_strategy_entry("ImportPathChange");
596 entry.from = Some("@patternfly/react-core/deprecated".to_string());
597 entry.to = Some("@patternfly/react-core".to_string());
598
599 match strategy_entry_to_fix_strategy(&entry) {
600 FixStrategy::ImportPathChange { old_path, new_path } => {
601 assert_eq!(old_path, "@patternfly/react-core/deprecated");
602 assert_eq!(new_path, "@patternfly/react-core");
603 }
604 other => panic!("Expected ImportPathChange, got {:?}", other),
605 }
606 }
607
608 #[test]
609 fn test_import_path_change_missing_fields_falls_to_manual() {
610 let mut entry = make_strategy_entry("ImportPathChange");
611 entry.from = Some("something".to_string());
612 match strategy_entry_to_fix_strategy(&entry) {
614 FixStrategy::Manual => {}
615 other => panic!("Expected Manual, got {:?}", other),
616 }
617 }
618
619 #[test]
620 fn test_ensure_dependency() {
621 let mut entry = make_strategy_entry("EnsureDependency");
622 entry.package = Some("@patternfly/react-core".to_string());
623 entry.new_version = Some("^6.0.0".to_string());
624
625 match strategy_entry_to_fix_strategy(&entry) {
626 FixStrategy::EnsureDependency {
627 package,
628 new_version,
629 } => {
630 assert_eq!(package, "@patternfly/react-core");
631 assert_eq!(new_version, "^6.0.0");
632 }
633 other => panic!("Expected EnsureDependency, got {:?}", other),
634 }
635 }
636
637 #[test]
638 fn test_ensure_dependency_missing_fields_falls_to_manual() {
639 let mut entry = make_strategy_entry("EnsureDependency");
640 entry.package = Some("something".to_string());
641 match strategy_entry_to_fix_strategy(&entry) {
643 FixStrategy::Manual => {}
644 other => panic!("Expected Manual, got {:?}", other),
645 }
646 }
647
648 #[test]
649 fn test_remove_prop_maps_to_remove_attribute() {
650 let entry = make_strategy_entry("RemoveProp");
651 match strategy_entry_to_fix_strategy(&entry) {
652 FixStrategy::RemoveAttribute => {}
653 other => panic!("Expected RemoveAttribute, got {:?}", other),
654 }
655 }
656
657 #[test]
658 fn test_prop_value_change_maps_to_llm() {
659 let entry = make_strategy_entry("PropValueChange");
660 match strategy_entry_to_fix_strategy(&entry) {
661 FixStrategy::Llm { .. } => {}
662 other => panic!("Expected Llm, got {:?}", other),
663 }
664 }
665
666 #[test]
667 fn test_unknown_strategy_maps_to_manual() {
668 let entry = make_strategy_entry("SomethingUnknown");
669 match strategy_entry_to_fix_strategy(&entry) {
670 FixStrategy::Manual => {}
671 other => panic!("Expected Manual, got {:?}", other),
672 }
673 }
674
675 #[test]
676 fn test_fix_plan_default_is_empty() {
677 let plan = FixPlan::default();
678 assert!(plan.files.is_empty());
679 assert!(plan.manual.is_empty());
680 assert!(plan.pending_llm.is_empty());
681 }
682
683 #[test]
684 fn test_fix_result_default_is_zero() {
685 let result = FixResult::default();
686 assert_eq!(result.files_modified, 0);
687 assert_eq!(result.edits_applied, 0);
688 assert_eq!(result.edits_skipped, 0);
689 assert!(result.errors.is_empty());
690 }
691
692 #[test]
693 fn test_fix_confidence_serde() {
694 assert_eq!(
695 serde_json::to_string(&FixConfidence::Exact).unwrap(),
696 "\"exact\""
697 );
698 assert_eq!(
699 serde_json::to_string(&FixConfidence::High).unwrap(),
700 "\"high\""
701 );
702 assert_eq!(
703 serde_json::to_string(&FixConfidence::Medium).unwrap(),
704 "\"medium\""
705 );
706 assert_eq!(
707 serde_json::to_string(&FixConfidence::Low).unwrap(),
708 "\"low\""
709 );
710 }
711
712 #[test]
713 fn test_fix_source_serde() {
714 assert_eq!(
715 serde_json::to_string(&FixSource::Pattern).unwrap(),
716 "\"pattern\""
717 );
718 assert_eq!(serde_json::to_string(&FixSource::Llm).unwrap(), "\"llm\"");
719 assert_eq!(
720 serde_json::to_string(&FixSource::Manual).unwrap(),
721 "\"manual\""
722 );
723 }
724
725 #[test]
726 fn test_strategy_entry_json_deserialization() {
727 let json = r#"{
728 "strategy": "Rename",
729 "mappings": [
730 {"from": "Chip", "to": "Label"},
731 {"from": "ChipGroup", "to": "LabelGroup"}
732 ]
733 }"#;
734 let entry: FixStrategyEntry = serde_json::from_str(json).unwrap();
735 assert_eq!(entry.strategy, "Rename");
736 assert_eq!(entry.mappings.len(), 2);
737 assert_eq!(entry.mappings[0].from.as_deref(), Some("Chip"));
738 assert_eq!(entry.mappings[0].to.as_deref(), Some("Label"));
739 }
740}