Skip to main content

morph_cli/core/
recipe.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Result;
4use indicatif::ProgressBar;
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum RecipeMaturity {
9    Experimental,
10    Beta,
11    #[default]
12    Stable,
13}
14
15impl std::fmt::Display for RecipeMaturity {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        match self {
18            Self::Experimental => write!(f, "experimental"),
19            Self::Beta => write!(f, "beta"),
20            Self::Stable => write!(f, "stable"),
21        }
22    }
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "kebab-case")]
27pub enum RecipeCategory {
28    Migration,
29    Cleanup,
30    Modernization,
31    Analysis,
32    Experimental,
33}
34
35impl std::fmt::Display for RecipeCategory {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::Migration => write!(f, "migration"),
39            Self::Cleanup => write!(f, "cleanup"),
40            Self::Modernization => write!(f, "modernization"),
41            Self::Analysis => write!(f, "analysis"),
42            Self::Experimental => write!(f, "experimental"),
43        }
44    }
45}
46
47impl std::str::FromStr for RecipeCategory {
48    type Err = anyhow::Error;
49
50    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
51        match s.to_lowercase().as_str() {
52            "migration" => Ok(Self::Migration),
53            "cleanup" => Ok(Self::Cleanup),
54            "modernization" => Ok(Self::Modernization),
55            "analysis" => Ok(Self::Analysis),
56            "experimental" => Ok(Self::Experimental),
57            _ => Err(anyhow::anyhow!("Invalid category: {}", s)),
58        }
59    }
60}
61
62#[derive(Debug, Clone)]
63pub struct RecipeMetadata {
64    pub name: &'static str,
65    pub description: &'static str,
66    pub supported_extensions: &'static [&'static str],
67    pub required_recipes: &'static [&'static str],
68    pub incompatible_recipes: &'static [&'static str],
69    /// Recipes that this recipe should run before (soft hint, not enforced as a hard dependency).
70    pub should_run_before: &'static [&'static str],
71    /// Recipes that this recipe should run after (soft hint, not enforced as a hard dependency).
72    pub should_run_after: &'static [&'static str],
73    pub maturity: RecipeMaturity,
74    pub category: RecipeCategory,
75    pub tags: &'static [&'static str],
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct DetectionFailure {
80    pub path: PathBuf,
81    pub error: String,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
85pub enum FileClassification {
86    Safe,
87    Risky,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct FileAnalysis {
92    pub path: PathBuf,
93    pub detected_patterns: Vec<String>,
94    pub confidence_score: u8,
95    pub classification: FileClassification,
96    pub is_transform_safe: bool,
97    #[serde(default)]
98    pub tags: Vec<String>,
99}
100
101pub fn compute_file_tags(
102    path: &Path,
103    content: Option<&str>,
104    detected_patterns: &[String],
105    is_risky: bool,
106    is_ignored: bool,
107) -> Vec<String> {
108    let mut tags = std::collections::BTreeSet::new();
109
110    if is_ignored {
111        tags.insert("ignored".to_string());
112        return tags.into_iter().collect();
113    }
114
115    let path_str = path.to_string_lossy().to_lowercase();
116    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
117
118    // typescript
119    if ext == "ts" || ext == "tsx" {
120        tags.insert("typescript".to_string());
121    }
122
123    // generated
124    if path_str.contains(".min.") 
125        || path_str.contains(".bundle.") 
126        || path_str.contains("/dist/") 
127        || path_str.contains("/build/")
128        || path_str.contains("/node_modules/") 
129        || path_str.contains("package-lock.json")
130        || path_str.contains("yarn.lock")
131    {
132        tags.insert("generated".to_string());
133    }
134
135    // risky
136    if is_risky 
137        || path_str.contains("risky") 
138        || detected_patterns.iter().any(|p| p.to_lowercase().contains("dynamic require") || p.to_lowercase().contains("eval"))
139    {
140        tags.insert("risky".to_string());
141    }
142
143    // react
144    let mut has_react = ext == "jsx" || ext == "tsx";
145    if !has_react {
146        if let Some(c) = content {
147            has_react = c.contains("React") || c.contains("react") || c.contains("JSX") || c.contains("jsx") || c.contains("useState") || c.contains("Component");
148        }
149    }
150    if has_react || detected_patterns.iter().any(|p| {
151        let lp = p.to_lowercase();
152        lp.contains("react") || lp.contains("jsx") || lp.contains("hooks")
153    }) {
154        tags.insert("react".to_string());
155    }
156
157    // commonjs
158    let mut has_cjs = ext == "cjs";
159    if !has_cjs {
160        if let Some(c) = content {
161            has_cjs = c.contains("require(") || c.contains("module.exports") || c.contains("exports.");
162        }
163    }
164    if has_cjs || detected_patterns.iter().any(|p| {
165        let lp = p.to_lowercase();
166        lp.contains("require") || lp.contains("exports")
167    }) {
168        tags.insert("commonjs".to_string());
169    }
170
171    // esm
172    let mut has_esm = ext == "mjs";
173    if !has_esm {
174        if let Some(c) = content {
175            has_esm = (c.contains("import ") && c.contains(" from ")) || c.contains("export ") || c.contains("export default");
176        }
177    }
178    if has_esm || detected_patterns.iter().any(|p| {
179        let lp = p.to_lowercase();
180        lp.contains("import") || lp.contains("export")
181    }) {
182        tags.insert("esm".to_string());
183    }
184
185    tags.into_iter().collect()
186}
187
188pub fn compute_tags_for_file(
189    path: &Path,
190    content_opt: Option<&str>,
191    patterns: &[String],
192    is_risky: bool,
193    is_ignored: bool,
194) -> Vec<String> {
195    if is_ignored {
196        return vec!["ignored".to_string()];
197    }
198    let content = content_opt.map(|s| s.to_string()).unwrap_or_else(|| {
199        std::fs::read_to_string(path).unwrap_or_default()
200    });
201    compute_file_tags(path, Some(&content), patterns, is_risky, is_ignored)
202}
203
204#[derive(Debug, Clone, Default)]
205pub struct DetectionReport {
206    pub analyses: Vec<FileAnalysis>,
207    pub skipped_files: Vec<PathBuf>,
208    pub total_files: usize,
209    pub parseable_files: usize,
210    pub failed_files: Vec<DetectionFailure>,
211}
212
213impl DetectionReport {
214    pub fn safe_transforms(&self) -> usize {
215        self.analyses
216            .iter()
217            .filter(|analysis| analysis.classification == FileClassification::Safe)
218            .count()
219    }
220
221    pub fn risky_transforms(&self) -> usize {
222        self.analyses
223            .iter()
224            .filter(|analysis| analysis.classification == FileClassification::Risky)
225            .count()
226    }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum TransformMode {
231    DryRun,
232    Write,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
236pub enum TransformConfidence {
237    Safe,
238    Moderate,
239    Risky,
240}
241
242impl std::fmt::Display for TransformConfidence {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            Self::Safe => write!(f, "safe"),
246            Self::Moderate => write!(f, "moderate"),
247            Self::Risky => write!(f, "risky"),
248        }
249    }
250}
251
252#[derive(Debug, Clone, Copy)]
253pub struct TransformOptions {
254    pub mode: TransformMode,
255    pub review: bool,
256    pub autofix: bool,
257    pub format: bool,
258    pub prettier: bool,
259    pub no_format: bool,
260}
261
262#[derive(Debug, Clone)]
263pub struct SkippedTransform {
264    pub path: PathBuf,
265    #[allow(dead_code)]
266    pub reason: String,
267}
268
269#[derive(Debug, Clone)]
270pub struct UnsupportedPatternReport {
271    pub path: PathBuf,
272    pub patterns: Vec<String>,
273}
274
275#[derive(Debug, Clone, Default)]
276pub struct TransformReport {
277    pub changed_files: Vec<PathBuf>,
278    pub skipped_files: Vec<SkippedTransform>,
279    pub unsupported_patterns: Vec<UnsupportedPatternReport>,
280    pub file_confidences: std::collections::HashMap<PathBuf, TransformConfidence>,
281}
282
283impl TransformReport {
284    pub fn changed_file_count(&self) -> usize {
285        self.changed_files.len()
286    }
287
288    pub fn populate_confidences(&mut self, detection_report: &DetectionReport) {
289        for path in &self.changed_files {
290            let confidence = self.calculate_confidence(path, detection_report);
291            self.file_confidences.insert(path.clone(), confidence);
292        }
293    }
294
295    pub fn calculate_confidence(
296        &self,
297        path: &Path,
298        detection_report: &DetectionReport,
299    ) -> TransformConfidence {
300        let mut score = 100;
301
302        // Penalty for unsupported patterns in this file
303        if self.unsupported_patterns.iter().any(|p| &p.path == path) {
304            score -= 40;
305        }
306
307        // Penalty based on detection confidence
308        if let Some(analysis) = detection_report.analyses.iter().find(|a| &a.path == path) {
309            if analysis.confidence_score < 50 {
310                score -= 30;
311            } else if analysis.confidence_score < 80 {
312                score -= 10;
313            }
314
315            if analysis.classification == FileClassification::Risky {
316                score -= 20;
317            }
318        }
319
320        // Penalty for parse failures in detection
321        if detection_report.failed_files.iter().any(|f| &f.path == path) {
322            score -= 50;
323        }
324
325        if score >= 80 {
326            TransformConfidence::Safe
327        } else if score >= 40 {
328            TransformConfidence::Moderate
329        } else {
330            TransformConfidence::Risky
331        }
332    }
333}
334
335pub trait Recipe: Send + Sync {
336    fn metadata(&self) -> &'static RecipeMetadata;
337    fn detect(&self, root: &Path, progress: &ProgressBar) -> Result<DetectionReport>;
338    fn transform(
339        &self,
340        report: &DetectionReport,
341        options: TransformOptions,
342    ) -> Result<TransformReport>;
343}