morph_cli/recipes/express_to_fastify/
mod.rs1#![allow(clippy::all)]
2pub mod analysis;
3pub mod detect;
4pub mod transform;
5
6use anyhow::Result;
7use indicatif::ProgressBar;
8use std::path::Path;
9
10use crate::core::cache::{CachedDetectionOutcome, load_detection, record_miss, save_detection};
11use crate::core::recipe::{
12 DetectionReport, FileAnalysis, FileClassification, Recipe, RecipeMetadata, TransformOptions,
13 TransformReport,
14};
15use crate::recipes::express_to_fastify::transform as tf;
16
17const METADATA: RecipeMetadata = RecipeMetadata {
18 name: "express-to-fastify",
19 description: "Analyze Express.js applications for Fastify migration readiness",
20 supported_extensions: &["js", "ts", "jsx", "tsx"],
21 required_recipes: &[],
22 incompatible_recipes: &[],
23 should_run_before: &[],
24 should_run_after: &[],
25 maturity: crate::core::recipe::RecipeMaturity::Beta,
26 category: crate::core::recipe::RecipeCategory::Analysis,
27 tags: &["risky", "backend", "express", "fastify", "web", "router", "http"],
28};
29
30pub struct ExpressToFastifyRecipe;
31
32impl ExpressToFastifyRecipe {
33 pub fn new() -> Self {
34 Self
35 }
36}
37
38impl Default for ExpressToFastifyRecipe {
39 fn default() -> Self {
40 Self::new()
41 }
42}
43
44impl Recipe for ExpressToFastifyRecipe {
45 fn metadata(&self) -> &'static RecipeMetadata {
46 &METADATA
47 }
48
49 fn detect(&self, root: &Path, progress: &ProgressBar) -> Result<DetectionReport> {
50 let mut report = DetectionReport::default();
51
52 for entry in walkdir::WalkDir::new(root)
53 .into_iter()
54 .filter_entry(|e| {
55 let name = e.file_name().to_string_lossy();
56 name != "node_modules" && name != ".git" && name != "target" && name != "dist" && name != "build"
57 })
58 .filter_map(|e| e.ok())
59 {
60 let path = entry.path();
61 if !path.is_file() {
62 continue;
63 }
64
65 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
66 if !["js", "ts", "jsx", "tsx"].contains(&ext) {
67 continue;
68 }
69
70 progress.set_message(format!("Analyzing {}", path.display()));
71 report.total_files += 1;
72
73 if let Some(outcome) = load_detection(METADATA.name, path) {
74 apply_cached_outcome(&mut report, outcome);
75 continue;
76 }
77 record_miss();
78
79 let mut detector = detect::ExpressDetector::new();
80 let analysis = match detector.detect(path) {
81 Some(analysis) => analysis,
82 None => {
83 let skipped = path.to_path_buf();
84 let _ = save_detection(
85 METADATA.name,
86 path,
87 &CachedDetectionOutcome::Skipped(skipped.clone()),
88 );
89 report.skipped_files.push(skipped);
90 continue;
91 }
92 };
93
94 let is_relevant = !analysis.routes.is_empty() || !analysis.express_apps.is_empty();
95 if !is_relevant {
96 let skipped = path.to_path_buf();
97 let _ = save_detection(
98 METADATA.name,
99 path,
100 &CachedDetectionOutcome::Skipped(skipped.clone()),
101 );
102 report.skipped_files.push(skipped);
103 continue;
104 }
105
106 let is_safe = analysis.complexity == analysis::ComplexityLevel::Simple
107 || analysis.complexity == analysis::ComplexityLevel::Moderate;
108
109 report.parseable_files += 1;
110 let file_analysis = FileAnalysis {
111 path: path.to_path_buf(),
112 detected_patterns: vec![format!("Complexity: {:?}", analysis.complexity)],
113 confidence_score: 100 - (analysis.risky_patterns.len() as u8 * 10).min(50),
114 classification: if is_safe {
115 FileClassification::Safe
116 } else {
117 FileClassification::Risky
118 },
119 is_transform_safe: is_safe,
120 tags: Default::default(),
121 };
122 let _ = save_detection(
123 METADATA.name,
124 path,
125 &CachedDetectionOutcome::Analysis(file_analysis.clone()),
126 );
127 report.analyses.push(file_analysis);
128 }
129
130 Ok(report)
131 }
132
133 fn transform(
134 &self,
135 report: &DetectionReport,
136 options: TransformOptions,
137 ) -> Result<TransformReport> {
138 let mut transform_report = TransformReport::default();
139
140 for analysis in &report.analyses {
141 let source = std::fs::read_to_string(&analysis.path)?;
142 let mut transformer = tf::ExpressToFastifyTransform::new();
143 let result = transformer.transform_source(&source, &analysis.path);
144
145 let has_actual_changes = result.changed && result.transformed != source;
146
147 if has_actual_changes {
148 transform_report.changed_files.push(analysis.path.clone());
149
150 if options.mode == crate::core::recipe::TransformMode::Write {
151 std::fs::write(&analysis.path, &result.transformed)?;
152 }
153 } else {
154 transform_report.skipped_files.push(crate::core::recipe::SkippedTransform {
155 path: analysis.path.clone(),
156 reason: "no changes detected".to_string(),
157 });
158 }
159
160 let mut diagnostics = result.unsupported.clone();
161 diagnostics.extend(result.warnings.clone());
162 if !diagnostics.is_empty() {
163 transform_report.unsupported_patterns.push(crate::core::recipe::UnsupportedPatternReport {
164 path: analysis.path.clone(),
165 patterns: diagnostics,
166 });
167 }
168 }
169
170 Ok(transform_report)
171 }
172}
173
174fn apply_cached_outcome(report: &mut DetectionReport, outcome: CachedDetectionOutcome) {
175 match outcome {
176 CachedDetectionOutcome::Analysis(analysis) => {
177 report.parseable_files += 1;
178 report.analyses.push(analysis);
179 }
180 CachedDetectionOutcome::Skipped(path) => report.skipped_files.push(path),
181 CachedDetectionOutcome::Failure(failure) => report.failed_files.push(failure),
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 #[test]
190 fn test_metadata() {
191 let recipe = ExpressToFastifyRecipe::new();
192 let meta = recipe.metadata();
193 assert_eq!(meta.name, "express-to-fastify");
194 }
195
196 #[test]
197 fn test_new() {
198 let recipe = ExpressToFastifyRecipe::new();
199 assert!(recipe.metadata().description.contains("Express"));
200 }
201}