Skip to main content

morph_cli/recipes/express_to_fastify/
mod.rs

1#![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}