Skip to main content

fix_engine_core/
lib.rs

1//! Fix engine core types.
2//!
3//! Defines the data model for planned fixes: text edits grouped by file,
4//! with support for pattern-based (deterministic) and LLM-assisted fixes.
5
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9
10/// Return type for [`load_strategies_and_families`]: `(strategies, family_entries)`.
11pub type StrategiesAndFamilies = (
12    BTreeMap<String, FixStrategy>,
13    BTreeMap<String, FixStrategyEntry>,
14);
15
16// Re-export shared types from konveyor-core so existing code continues to compile.
17pub use konveyor_core::fix::{
18    FixConfidence, FixSource, FixStrategyEntry, MappingEntry as StrategyMappingEntry,
19    MemberMappingEntry,
20};
21
22// Re-export konveyor-core types for convenience
23pub use konveyor_core::incident;
24pub use konveyor_core::report;
25pub use konveyor_core::rule;
26
27/// A single text replacement within a file.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TextEdit {
30    /// 1-indexed line number where the edit applies.
31    pub line: u32,
32    /// The original text to find on this line.
33    pub old_text: String,
34    /// The replacement text.
35    pub new_text: String,
36    /// Rule ID that generated this fix.
37    pub rule_id: String,
38    /// Human-readable description of what this fix does.
39    pub description: String,
40    /// When true, replace ALL occurrences of old_text on this line (not just the first).
41    /// Used for prefix replacements (e.g. CssVariablePrefix) where a single line
42    /// may contain multiple instances of the old prefix.
43    #[serde(default)]
44    pub replace_all: bool,
45}
46
47/// A planned fix for a single incident.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct PlannedFix {
50    /// The text edits to apply.
51    pub edits: Vec<TextEdit>,
52    /// Confidence level.
53    pub confidence: FixConfidence,
54    /// How the fix was generated.
55    pub source: FixSource,
56    /// The rule ID this fix addresses.
57    pub rule_id: String,
58    /// File URI from the incident.
59    pub file_uri: String,
60    /// Line number from the incident.
61    pub line: u32,
62    /// Description of what this fix does.
63    pub description: String,
64}
65
66/// A fix plan: all planned fixes grouped by file.
67#[derive(Debug, Clone, Default, Serialize, Deserialize)]
68pub struct FixPlan {
69    /// Fixes grouped by file path.
70    pub files: BTreeMap<PathBuf, Vec<PlannedFix>>,
71    /// Incidents that could not be auto-fixed and need manual attention.
72    pub manual: Vec<ManualFixItem>,
73    /// Incidents pending LLM-assisted fix.
74    pub pending_llm: Vec<LlmFixRequest>,
75    /// Number of edits removed during plan-time deduplication because a more
76    /// specific edit on the same line already covers the same text region.
77    #[serde(default)]
78    pub edits_subsumed: usize,
79}
80
81/// An incident that requires manual fixing.
82#[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/// A request to send to the LLM for fix generation.
92#[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    /// The full source content of the file (for context).
101    pub source: Option<String>,
102    /// Labels from the violation (e.g., "family=Modal", "change-type=prop-to-child").
103    /// Used to coalesce related rules into coherent migration groups.
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub labels: Vec<String>,
106}
107
108/// Result of applying a fix plan.
109#[derive(Debug, Default)]
110pub struct FixResult {
111    /// Number of files modified.
112    pub files_modified: usize,
113    /// Number of edits applied.
114    pub edits_applied: usize,
115    /// Number of edits skipped (line out of bounds or text not found).
116    pub edits_skipped: usize,
117    /// Number of edits subsumed by a more specific edit on the same line.
118    pub edits_subsumed: usize,
119    /// Errors encountered.
120    pub errors: Vec<String>,
121    /// Paths of files that were actually modified on disk.
122    pub modified_files: Vec<std::path::PathBuf>,
123}
124
125/// A rename mapping: old name -> new name.
126/// Used for prop renames, component renames, import renames, etc.
127#[derive(Debug, Clone)]
128pub struct RenameMapping {
129    pub old: String,
130    pub new: String,
131}
132
133/// Known fix strategies keyed by rule ID.
134/// Each entry defines how to transform incidents from that rule into text edits.
135#[derive(Debug, Clone)]
136pub enum FixStrategy {
137    /// Simple text replacement: rename the matched text.
138    /// The mapping is propName/componentName/importedName old -> new.
139    Rename(Vec<RenameMapping>),
140    /// Remove the matched attribute (e.g., delete the entire prop from a JSX tag,
141    /// or remove a decorator in Python, etc.).
142    RemoveAttribute,
143    /// Replace an import/include source path.
144    ImportPathChange { old_path: String, new_path: String },
145    /// Replace a CSS variable/class prefix.
146    CssVariablePrefix {
147        old_prefix: String,
148        new_prefix: String,
149        /// CSS classes to exclude from this prefix swap (dead after swap).
150        exclude_patterns: Vec<String>,
151    },
152    /// Ensure a dependency exists at the correct version in the project manifest
153    /// (e.g., package.json for Node.js, Cargo.toml for Rust, go.mod for Go).
154    EnsureDependency {
155        package: String,
156        new_version: String,
157    },
158    /// No auto-fix available -- flag for manual review.
159    Manual,
160    /// Send to LLM for fix generation.
161    /// `context` carries pre-formatted strategy data (strategy type, from/to,
162    /// mappings, target structure, etc.) from the `FixStrategyEntry`.
163    /// `None` when the rule fell through to Llm via label inference or default.
164    Llm { context: Option<String> },
165}
166
167/// Convert a `FixStrategyEntry` (from the shared `konveyor-core` crate) to
168/// a runtime `FixStrategy`.
169///
170/// When `mappings` is populated (consolidated rule), builds a multi-mapping
171/// `FixStrategy::Rename` or extracts multiple `RemoveProp` targets.
172pub 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            // Collect from mappings array (consolidated rule)
177            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            // Fall back to top-level from/to (single-rule strategy)
186            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        // v2 SD-pipeline strategies -- these require structural
239        // transformations that only the LLM can handle.
240        "ChildToProp"
241        | "PropToChild"
242        | "PropToChildren"
243        | "CompositionChange"
244        | "DeprecatedMigration" => FixStrategy::Llm {
245            context: Some(format_strategy_context(entry)),
246        },
247        // Family-level migration: the entry carries the complete target
248        // component structure. Format it as a rich context block.
249        "FamilyMigration" => FixStrategy::Llm {
250            context: Some(format_family_migration_context(entry)),
251        },
252        _ => FixStrategy::Manual,
253    }
254}
255
256/// Format a per-rule `FixStrategyEntry` into a human-readable context block
257/// for the LLM prompt. Includes the strategy type, from/to mappings,
258/// component/prop targets, member mappings, etc.
259fn 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
307/// Format a family-level `FixStrategyEntry` (keyed `family:<Name>`) into a
308/// rich context block for the LLM prompt.
309fn 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    // Deprecated -> v6 migration context: complete prop mapping with types.
385    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        // Matching props (survived the migration, may have type changes)
392        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        // New props (only on v6, not on deprecated)
419        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                // Auto-detect render-prop patterns
424                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        // Removed props (only on deprecated, no v6 equivalent)
443        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
454/// Load fix strategies from a JSON file and also extract raw family-level
455/// `FixStrategyEntry` entries (keyed `family:*`) for use in family consolidation.
456///
457/// Returns `(strategies, family_entries)`.
458pub 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
474/// Load fix strategies from a JSON file.
475///
476/// Returns a map of rule_id -> FixStrategy.
477pub 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        // missing `to`
613        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        // missing new_version
642        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}