use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub type StrategiesAndFamilies = (
BTreeMap<String, FixStrategy>,
BTreeMap<String, FixStrategyEntry>,
);
pub use konveyor_core::fix::{
FixConfidence, FixSource, FixStrategyEntry, MappingEntry as StrategyMappingEntry,
MemberMappingEntry,
};
pub use konveyor_core::incident;
pub use konveyor_core::report;
pub use konveyor_core::rule;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TextEdit {
pub line: u32,
pub old_text: String,
pub new_text: String,
pub rule_id: String,
pub description: String,
#[serde(default)]
pub replace_all: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlannedFix {
pub edits: Vec<TextEdit>,
pub confidence: FixConfidence,
pub source: FixSource,
pub rule_id: String,
pub file_uri: String,
pub line: u32,
pub description: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FixPlan {
pub files: BTreeMap<PathBuf, Vec<PlannedFix>>,
pub manual: Vec<ManualFixItem>,
pub pending_llm: Vec<LlmFixRequest>,
#[serde(default)]
pub edits_subsumed: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ManualFixItem {
pub rule_id: String,
pub file_uri: String,
pub line: u32,
pub message: String,
pub code_snip: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmFixRequest {
pub rule_id: String,
pub file_uri: String,
pub file_path: PathBuf,
pub line: u32,
pub message: String,
pub code_snip: Option<String>,
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub labels: Vec<String>,
}
#[derive(Debug, Default)]
pub struct FixResult {
pub files_modified: usize,
pub edits_applied: usize,
pub edits_skipped: usize,
pub edits_subsumed: usize,
pub errors: Vec<String>,
pub modified_files: Vec<std::path::PathBuf>,
}
#[derive(Debug, Clone)]
pub struct RenameMapping {
pub old: String,
pub new: String,
}
#[derive(Debug, Clone)]
pub enum FixStrategy {
Rename(Vec<RenameMapping>),
RemoveAttribute,
ImportPathChange { old_path: String, new_path: String },
CssVariablePrefix {
old_prefix: String,
new_prefix: String,
exclude_patterns: Vec<String>,
},
EnsureDependency {
package: String,
new_version: String,
},
Manual,
Llm { context: Option<String> },
}
pub fn strategy_entry_to_fix_strategy(entry: &FixStrategyEntry) -> FixStrategy {
match entry.strategy.as_str() {
"Rename" => {
let mut renames: Vec<RenameMapping> = Vec::new();
for m in &entry.mappings {
if let (Some(from), Some(to)) = (&m.from, &m.to) {
renames.push(RenameMapping {
old: from.clone(),
new: to.clone(),
});
}
}
if renames.is_empty() {
if let (Some(from), Some(to)) = (&entry.from, &entry.to) {
renames.push(RenameMapping {
old: from.clone(),
new: to.clone(),
});
}
}
if renames.is_empty() {
FixStrategy::Manual
} else {
FixStrategy::Rename(renames)
}
}
"RemoveProp" => FixStrategy::RemoveAttribute,
"CssVariablePrefix" => {
if let (Some(from), Some(to)) = (&entry.from, &entry.to) {
FixStrategy::CssVariablePrefix {
old_prefix: from.clone(),
new_prefix: to.clone(),
exclude_patterns: entry.exclude_patterns.clone(),
}
} else {
FixStrategy::Manual
}
}
"ImportPathChange" => {
if let (Some(from), Some(to)) = (&entry.from, &entry.to) {
FixStrategy::ImportPathChange {
old_path: from.clone(),
new_path: to.clone(),
}
} else {
FixStrategy::Manual
}
}
"EnsureDependency" => {
if let (Some(package), Some(new_version)) = (&entry.package, &entry.new_version) {
FixStrategy::EnsureDependency {
package: package.clone(),
new_version: new_version.clone(),
}
} else {
FixStrategy::Manual
}
}
"PropValueChange" | "PropTypeChange" => FixStrategy::Llm {
context: Some(format_strategy_context(entry)),
},
"LlmAssisted" => FixStrategy::Llm {
context: Some(format_strategy_context(entry)),
},
"ChildToProp"
| "PropToChild"
| "PropToChildren"
| "CompositionChange"
| "DeprecatedMigration" => FixStrategy::Llm {
context: Some(format_strategy_context(entry)),
},
"FamilyMigration" => FixStrategy::Llm {
context: Some(format_family_migration_context(entry)),
},
_ => FixStrategy::Manual,
}
}
fn format_strategy_context(entry: &FixStrategyEntry) -> String {
let mut parts = Vec::new();
parts.push(format!("Strategy: {}", entry.strategy));
if let Some(ref c) = entry.component {
parts.push(format!("Component: {}", c));
}
if let Some(ref p) = entry.prop {
parts.push(format!("Prop: {}", p));
}
if let Some(ref from) = entry.from {
parts.push(format!("From: {}", from));
}
if let Some(ref to) = entry.to {
parts.push(format!("To: {}", to));
}
if let Some(ref repl) = entry.replacement {
parts.push(format!("Replacement: {}", repl));
}
if !entry.mappings.is_empty() {
let maps: Vec<String> = entry
.mappings
.iter()
.filter_map(|m| match (&m.from, &m.to) {
(Some(f), Some(t)) => Some(format!(" {} -> {}", f, t)),
_ => None,
})
.collect();
if !maps.is_empty() {
parts.push(format!("Mappings:\n{}", maps.join("\n")));
}
}
if !entry.member_mappings.is_empty() {
let maps: Vec<String> = entry
.member_mappings
.iter()
.map(|m| format!(" {} -> {}", m.old_name, m.new_name))
.collect();
parts.push(format!("Member mappings:\n{}", maps.join("\n")));
}
if !entry.removed_members.is_empty() {
parts.push(format!(
"Removed members: {}",
entry.removed_members.join(", ")
));
}
parts.join("\n")
}
fn format_family_migration_context(entry: &FixStrategyEntry) -> String {
let mut parts = Vec::new();
parts.push("Strategy: FamilyMigration".to_string());
if let Some(ref target) = entry.target_structure {
parts.push(format!(
"\nTarget structure (correct v6 composition):\n```jsx\n{}\n```",
target
));
}
if let Some(ref comp) = entry.component {
parts.push(format!("Component: {}", comp));
}
if !entry.retained_props.is_empty() {
parts.push(format!(
"Props that stay on root: {}",
entry.retained_props.join(", ")
));
}
if !entry.prop_to_child.is_empty() {
let maps: Vec<String> = entry
.prop_to_child
.iter()
.map(|(prop, child)| format!(" {} -> <{} />", prop, child))
.collect();
parts.push(format!(
"Props that move to child components:\n{}",
maps.join("\n")
));
}
if !entry.child_props_to_parent.is_empty() {
let maps: Vec<String> = entry
.child_props_to_parent
.iter()
.map(|(child_prop, parent_prop)| format!(" {} -> {}", child_prop, parent_prop))
.collect();
parts.push(format!(
"Child props that move to parent:\n{}",
maps.join("\n")
));
}
if !entry.removed_children.is_empty() {
parts.push(format!(
"Removed children: {}",
entry.removed_children.join(", ")
));
}
if !entry.new_imports.is_empty() {
let src = entry.import_source.as_deref().unwrap_or("(same package)");
parts.push(format!(
"Add imports: {} from '{}'",
entry.new_imports.join(", "),
src
));
}
if !entry.removed_imports.is_empty() {
parts.push(format!(
"Remove imports: {}",
entry.removed_imports.join(", ")
));
}
if !entry.prop_value_changes.is_empty() {
let mut lines = Vec::new();
for (prop, mappings) in &entry.prop_value_changes {
for m in mappings {
if let (Some(from), Some(to)) = (&m.from, &m.to) {
lines.push(format!(" {}: {} -> {}", prop, from, to));
}
}
}
if !lines.is_empty() {
parts.push(format!("Prop value changes:\n{}", lines.join("\n")));
}
}
if let Some(ref dm) = entry.deprecated_migration {
parts.push(format!(
"\nDeprecated -> v6 migration:\n Old import: {}\n New import: {}",
dm.old_package, dm.new_package
));
if !dm.matching_props.is_empty() {
let mut lines = Vec::new();
for p in &dm.matching_props {
if p.type_changed {
lines.push(format!(
" {} -> {} (TYPE CHANGED):\n old: {}\n new: {}",
p.old_name,
p.new_name,
p.old_type.as_deref().unwrap_or("?"),
p.new_type.as_deref().unwrap_or("?")
));
} else {
let typ = p.new_type.as_deref().unwrap_or("?");
if p.old_name == p.new_name {
lines.push(format!(" {}: {} (unchanged)", p.new_name, typ));
} else {
lines.push(format!(
" {} -> {}: {} (renamed, type unchanged)",
p.old_name, p.new_name, typ
));
}
}
}
parts.push(format!("Matching props:\n{}", lines.join("\n")));
}
if !dm.new_props.is_empty() {
let mut lines = Vec::new();
for (name, typ) in &dm.new_props {
let mut line = format!(" {}: {}", name, typ);
if (typ.contains("=> React.ReactNode")
|| typ.contains("=> ReactNode")
|| typ.contains("=> React.ReactElement"))
&& typ.contains("=>")
{
line.push_str(
"\n NOTE: This is a render function. Pass a function REFERENCE, \
not a function call.",
);
}
lines.push(line);
}
parts.push(format!(
"New props on v6 (not on deprecated version):\n{}",
lines.join("\n")
));
}
if !dm.removed_props.is_empty() {
parts.push(format!(
"Removed props (no v6 equivalent): {}",
dm.removed_props.join(", ")
));
}
}
parts.join("\n")
}
pub fn load_strategies_and_families(
path: &Path,
) -> Result<StrategiesAndFamilies, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let entries: BTreeMap<String, FixStrategyEntry> = serde_json::from_str(&content)?;
let strategies = entries
.iter()
.map(|(rule_id, entry)| (rule_id.clone(), strategy_entry_to_fix_strategy(entry)))
.collect();
let families = entries
.into_iter()
.filter(|(k, _)| k.starts_with("family:"))
.collect();
Ok((strategies, families))
}
pub fn load_strategies_from_json(
path: &Path,
) -> Result<BTreeMap<String, FixStrategy>, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)?;
let entries: BTreeMap<String, FixStrategyEntry> = serde_json::from_str(&content)?;
let strategies = entries
.iter()
.map(|(rule_id, entry)| (rule_id.clone(), strategy_entry_to_fix_strategy(entry)))
.collect();
Ok(strategies)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_strategy_entry(strategy: &str) -> FixStrategyEntry {
FixStrategyEntry::new(strategy)
}
#[test]
fn test_rename_with_top_level_from_to() {
let mut entry = make_strategy_entry("Rename");
entry.from = Some("Chip".to_string());
entry.to = Some("Label".to_string());
match strategy_entry_to_fix_strategy(&entry) {
FixStrategy::Rename(mappings) => {
assert_eq!(mappings.len(), 1);
assert_eq!(mappings[0].old, "Chip");
assert_eq!(mappings[0].new, "Label");
}
other => panic!("Expected Rename, got {:?}", other),
}
}
#[test]
fn test_rename_with_mappings_array() {
let mut entry = make_strategy_entry("Rename");
entry.mappings = vec![
StrategyMappingEntry {
from: Some("Chip".to_string()),
to: Some("Label".to_string()),
component: None,
prop: None,
},
StrategyMappingEntry {
from: Some("ChipGroup".to_string()),
to: Some("LabelGroup".to_string()),
component: None,
prop: None,
},
];
match strategy_entry_to_fix_strategy(&entry) {
FixStrategy::Rename(mappings) => {
assert_eq!(mappings.len(), 2);
assert_eq!(mappings[0].old, "Chip");
assert_eq!(mappings[0].new, "Label");
assert_eq!(mappings[1].old, "ChipGroup");
assert_eq!(mappings[1].new, "LabelGroup");
}
other => panic!("Expected Rename, got {:?}", other),
}
}
#[test]
fn test_rename_mappings_take_precedence_over_top_level() {
let mut entry = make_strategy_entry("Rename");
entry.from = Some("TopLevel".to_string());
entry.to = Some("ShouldBeIgnored".to_string());
entry.mappings = vec![StrategyMappingEntry {
from: Some("FromMapping".to_string()),
to: Some("ToMapping".to_string()),
component: None,
prop: None,
}];
match strategy_entry_to_fix_strategy(&entry) {
FixStrategy::Rename(mappings) => {
assert_eq!(mappings.len(), 1);
assert_eq!(mappings[0].old, "FromMapping");
}
other => panic!("Expected Rename, got {:?}", other),
}
}
#[test]
fn test_css_variable_prefix() {
let mut entry = make_strategy_entry("CssVariablePrefix");
entry.from = Some("pf-v5-".to_string());
entry.to = Some("pf-v6-".to_string());
match strategy_entry_to_fix_strategy(&entry) {
FixStrategy::CssVariablePrefix {
old_prefix,
new_prefix,
exclude_patterns,
} => {
assert_eq!(old_prefix, "pf-v5-");
assert_eq!(new_prefix, "pf-v6-");
assert!(exclude_patterns.is_empty());
}
other => panic!("Expected CssVariablePrefix, got {:?}", other),
}
}
#[test]
fn test_css_variable_prefix_missing_fields_falls_to_manual() {
let entry = make_strategy_entry("CssVariablePrefix");
match strategy_entry_to_fix_strategy(&entry) {
FixStrategy::Manual => {}
other => panic!("Expected Manual, got {:?}", other),
}
}
#[test]
fn test_import_path_change() {
let mut entry = make_strategy_entry("ImportPathChange");
entry.from = Some("@patternfly/react-core/deprecated".to_string());
entry.to = Some("@patternfly/react-core".to_string());
match strategy_entry_to_fix_strategy(&entry) {
FixStrategy::ImportPathChange { old_path, new_path } => {
assert_eq!(old_path, "@patternfly/react-core/deprecated");
assert_eq!(new_path, "@patternfly/react-core");
}
other => panic!("Expected ImportPathChange, got {:?}", other),
}
}
#[test]
fn test_import_path_change_missing_fields_falls_to_manual() {
let mut entry = make_strategy_entry("ImportPathChange");
entry.from = Some("something".to_string());
match strategy_entry_to_fix_strategy(&entry) {
FixStrategy::Manual => {}
other => panic!("Expected Manual, got {:?}", other),
}
}
#[test]
fn test_ensure_dependency() {
let mut entry = make_strategy_entry("EnsureDependency");
entry.package = Some("@patternfly/react-core".to_string());
entry.new_version = Some("^6.0.0".to_string());
match strategy_entry_to_fix_strategy(&entry) {
FixStrategy::EnsureDependency {
package,
new_version,
} => {
assert_eq!(package, "@patternfly/react-core");
assert_eq!(new_version, "^6.0.0");
}
other => panic!("Expected EnsureDependency, got {:?}", other),
}
}
#[test]
fn test_ensure_dependency_missing_fields_falls_to_manual() {
let mut entry = make_strategy_entry("EnsureDependency");
entry.package = Some("something".to_string());
match strategy_entry_to_fix_strategy(&entry) {
FixStrategy::Manual => {}
other => panic!("Expected Manual, got {:?}", other),
}
}
#[test]
fn test_remove_prop_maps_to_remove_attribute() {
let entry = make_strategy_entry("RemoveProp");
match strategy_entry_to_fix_strategy(&entry) {
FixStrategy::RemoveAttribute => {}
other => panic!("Expected RemoveAttribute, got {:?}", other),
}
}
#[test]
fn test_prop_value_change_maps_to_llm() {
let entry = make_strategy_entry("PropValueChange");
match strategy_entry_to_fix_strategy(&entry) {
FixStrategy::Llm { .. } => {}
other => panic!("Expected Llm, got {:?}", other),
}
}
#[test]
fn test_unknown_strategy_maps_to_manual() {
let entry = make_strategy_entry("SomethingUnknown");
match strategy_entry_to_fix_strategy(&entry) {
FixStrategy::Manual => {}
other => panic!("Expected Manual, got {:?}", other),
}
}
#[test]
fn test_fix_plan_default_is_empty() {
let plan = FixPlan::default();
assert!(plan.files.is_empty());
assert!(plan.manual.is_empty());
assert!(plan.pending_llm.is_empty());
}
#[test]
fn test_fix_result_default_is_zero() {
let result = FixResult::default();
assert_eq!(result.files_modified, 0);
assert_eq!(result.edits_applied, 0);
assert_eq!(result.edits_skipped, 0);
assert!(result.errors.is_empty());
}
#[test]
fn test_fix_confidence_serde() {
assert_eq!(
serde_json::to_string(&FixConfidence::Exact).unwrap(),
"\"exact\""
);
assert_eq!(
serde_json::to_string(&FixConfidence::High).unwrap(),
"\"high\""
);
assert_eq!(
serde_json::to_string(&FixConfidence::Medium).unwrap(),
"\"medium\""
);
assert_eq!(
serde_json::to_string(&FixConfidence::Low).unwrap(),
"\"low\""
);
}
#[test]
fn test_fix_source_serde() {
assert_eq!(
serde_json::to_string(&FixSource::Pattern).unwrap(),
"\"pattern\""
);
assert_eq!(serde_json::to_string(&FixSource::Llm).unwrap(), "\"llm\"");
assert_eq!(
serde_json::to_string(&FixSource::Manual).unwrap(),
"\"manual\""
);
}
#[test]
fn test_strategy_entry_json_deserialization() {
let json = r#"{
"strategy": "Rename",
"mappings": [
{"from": "Chip", "to": "Label"},
{"from": "ChipGroup", "to": "LabelGroup"}
]
}"#;
let entry: FixStrategyEntry = serde_json::from_str(json).unwrap();
assert_eq!(entry.strategy, "Rename");
assert_eq!(entry.mappings.len(), 2);
assert_eq!(entry.mappings[0].from.as_deref(), Some("Chip"));
assert_eq!(entry.mappings[0].to.as_deref(), Some("Label"));
}
}