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 pub should_run_before: &'static [&'static str],
71 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 if ext == "ts" || ext == "tsx" {
120 tags.insert("typescript".to_string());
121 }
122
123 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 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 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 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 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 if self.unsupported_patterns.iter().any(|p| &p.path == path) {
304 score -= 40;
305 }
306
307 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 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}