Skip to main content

morph_cli/recipes/express_to_fastify/
transform.rs

1#![allow(clippy::all)]
2use swc_common::{FileName, SourceMap, SourceMapper, Spanned, sync::Lrc};
3use swc_ecma_ast::*;
4use swc_ecma_parser::{Parser, StringInput, Syntax, lexer::Lexer};
5use swc_ecma_visit::{Visit, VisitWith};
6
7#[derive(Debug, Clone, Default)]
8pub struct TransformStats {
9    pub express_init_count: usize,
10    pub route_count: usize,
11    pub router_count: usize,
12    pub async_handlers: usize,
13    pub middleware_count: usize,
14    pub res_json_calls: usize,
15    pub res_send_calls: usize,
16}
17
18#[derive(Clone)]
19#[allow(dead_code)]
20enum TransformChange {
21    ExpressInit,
22    RouterInit,
23    ImportMigration { _old: String, new: String },
24    BodyParserPlugin,
25    Replacement { original: String, replacement: String },
26}
27
28impl TransformChange {
29    fn apply(&self, source: &str) -> String {
30        match self {
31            Self::ExpressInit => source
32                .replace("express()", "fastify()")
33                .replace("require('express')", "require('fastify')")
34                .replace(" from 'express'", " from 'fastify'"),
35            Self::RouterInit => source.replace("Router()", "Router()"),
36            Self::ImportMigration { new, .. } => {
37                source.replace(" from 'express'", &format!(" from '{}'", new))
38            }
39            Self::BodyParserPlugin => source
40                .replace("bodyParser.json()", "// TODO: register @fastify/json")
41                .replace("body-parser", "// TODO: @fastify/json"),
42            Self::Replacement { original, replacement } => source.replace(original, replacement),
43        }
44    }
45}
46
47pub struct ExpressToFastifyTransform {
48    warnings: Vec<String>,
49    unsupported: Vec<String>,
50    stats: TransformStats,
51}
52
53impl ExpressToFastifyTransform {
54    pub fn new() -> Self {
55        Self {
56            warnings: Vec::new(),
57            unsupported: Vec::new(),
58            stats: TransformStats::default(),
59        }
60    }
61
62    pub fn transform_source(&mut self, source: &str, path: &std::path::Path) -> TransformOutcome {
63        let cm: Lrc<SourceMap> = Lrc::new(SourceMap::default());
64        let fm = cm.new_source_file(
65            FileName::Real(path.to_path_buf()).into(),
66            source.to_string(),
67        );
68
69        let syntax = if path.to_string_lossy().ends_with(".ts")
70            || path.to_string_lossy().ends_with(".tsx")
71        {
72            Syntax::Typescript(Default::default())
73        } else {
74            Syntax::Es(Default::default())
75        };
76
77        let lexer = Lexer::new(syntax, Default::default(), StringInput::from(&*fm), None);
78        let mut parser = Parser::new_from(lexer);
79        let module = match parser.parse_module() {
80            Ok(m) => m,
81            Err(_) => return TransformOutcome::unsupported("Parse error".to_string()),
82        };
83
84        let mut visitor = TransformVisitor::new(cm.clone());
85        visitor.visit_module(&module);
86
87        let changes = visitor.changes.clone();
88        let changes_empty = changes.is_empty();
89        self.stats = visitor.stats.clone();
90        self.warnings = visitor.warnings.clone();
91        self.unsupported = visitor.unsupported.clone();
92
93        let mut output = source.to_string();
94        for change in changes {
95            output = change.apply(&output);
96        }
97        output = output.replace(";;", ";");
98
99        TransformOutcome {
100            transformed: output,
101            changed: !changes_empty,
102            warnings: self.warnings.clone(),
103            unsupported: self.unsupported.clone(),
104            stats: self.stats.clone(),
105        }
106    }
107}
108
109impl Default for ExpressToFastifyTransform {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115struct TransformVisitor {
116    changes: Vec<TransformChange>,
117    warnings: Vec<String>,
118    unsupported: Vec<String>,
119    stats: TransformStats,
120    cm: Lrc<SourceMap>,
121    processed_spans: Vec<swc_common::Span>,
122}
123
124struct RouteChain {
125    base_obj: String,
126    path: String,
127    calls: Vec<(String, Vec<String>)>,
128    _span: swc_common::Span,
129}
130
131fn extract_route_chain(call: &CallExpr, cm: &SourceMap) -> Option<RouteChain> {
132    let mut current_expr = Expr::Call(call.clone());
133    let mut calls = Vec::new();
134    let mut path = None;
135    let mut base_obj = None;
136
137    loop {
138        let temp_expr = current_expr.clone();
139        match temp_expr {
140            Expr::Call(c) => {
141                let mut is_route_method = false;
142                if let Callee::Expr(callee_expr) = &c.callee {
143                    if let Expr::Member(MemberExpr { obj, prop, .. }) = callee_expr.as_ref() {
144                        if let MemberProp::Ident(method_ident) = prop {
145                            let method_name = method_ident.sym.as_ref().to_lowercase();
146                            if ["get", "post", "put", "delete", "patch", "head", "options"].contains(&method_name.as_str()) {
147                                is_route_method = true;
148                                let args_list: Vec<String> = c.args.iter().map(|arg| {
149                                    cm.span_to_snippet(arg.span()).unwrap_or_default()
150                                }).collect();
151                                
152                                calls.push((method_name, args_list));
153                                current_expr = *obj.clone();
154                            }
155                        }
156                    }
157                }
158                
159                if !is_route_method {
160                    if let Callee::Expr(callee_expr) = &c.callee {
161                        if let Expr::Member(MemberExpr { obj, prop, .. }) = callee_expr.as_ref() {
162                            if let MemberProp::Ident(prop_ident) = prop {
163                                if prop_ident.sym.as_ref() == "route" {
164                                    if let Expr::Ident(obj_ident) = obj.as_ref() {
165                                        base_obj = Some(obj_ident.sym.as_ref().to_string());
166                                    }
167                                    if let Some(first_arg) = c.args.first() {
168                                        path = Some(cm.span_to_snippet(first_arg.span()).unwrap_or_default());
169                                    }
170                                }
171                            }
172                        }
173                    }
174                    break;
175                }
176            }
177            _ => break,
178        }
179    }
180
181    if let (Some(base), Some(p), false) = (base_obj, path, calls.is_empty()) {
182        calls.reverse();
183        Some(RouteChain {
184            base_obj: base,
185            path: p,
186            calls,
187            _span: call.span(),
188        })
189    } else {
190        None
191    }
192}
193
194fn migrate_handler(handler_src: &str) -> String {
195    let mut result = handler_src.to_string();
196    
197    // Parameter signatures
198    result = result.replace("(req, res, next)", "(req, reply, next)");
199    result = result.replace("(request, response, next)", "(request, reply, next)");
200    result = result.replace("(req, res)", "(req, reply)");
201    result = result.replace("(request, response)", "(request, reply)");
202    result = result.replace("async (req, res, next)", "async (req, reply, next)");
203    result = result.replace("async (request, response, next)", "async (request, reply, next)");
204    result = result.replace("async (req, res)", "async (req, reply)");
205    result = result.replace("async (request, response)", "async (request, reply)");
206    
207    // Express-style status/json/send combinations
208    result = result.replace("res.status(", "reply.status(");
209    result = result.replace("response.status(", "reply.status(");
210    result = result.replace("res.send(", "reply.send(");
211    result = result.replace("response.send(", "reply.send(");
212    result = result.replace("res.json(", "reply.send(");
213    result = result.replace("response.json(", "reply.send(");
214    result = result.replace("res.sendStatus(", "reply.status(");
215    result = result.replace(".json(", ".send(");
216    
217    // Generic replacements for leftover res/response
218    result = result.replace("res.", "reply.");
219    result = result.replace("response.", "reply.");
220    
221    // Basic query/params access:
222    // Support converting req.param('name') to req.params.name
223    let mut final_res = String::new();
224    let mut remaining = result.as_str();
225    while let Some(idx) = remaining.find("req.param(") {
226        final_res.push_str(&remaining[..idx]);
227        let rest = &remaining[idx + 10..];
228        if let Some(end_idx) = rest.find(')') {
229            let param_arg = rest[..end_idx].trim();
230            let clean_name = param_arg.trim_matches(|c| c == '\'' || c == '"');
231            final_res.push_str(&format!("req.params.{}", clean_name));
232            remaining = &rest[end_idx + 1..];
233        } else {
234            final_res.push_str("req.param(");
235            remaining = rest;
236        }
237    }
238    final_res.push_str(remaining);
239    result = final_res;
240
241    let mut final_res_req = String::new();
242    let mut remaining_req = result.as_str();
243    while let Some(idx) = remaining_req.find("request.param(") {
244        final_res_req.push_str(&remaining_req[..idx]);
245        let rest = &remaining_req[idx + 14..];
246        if let Some(end_idx) = rest.find(')') {
247            let param_arg = rest[..end_idx].trim();
248            let clean_name = param_arg.trim_matches(|c| c == '\'' || c == '"');
249            final_res_req.push_str(&format!("request.params.{}", clean_name));
250            remaining_req = &rest[end_idx + 1..];
251        } else {
252            final_res_req.push_str("request.param(");
253            remaining_req = rest;
254        }
255    }
256    final_res_req.push_str(remaining_req);
257    result = final_res_req;
258
259    result
260}
261
262fn migrate_route_call(
263    base: &str,
264    method: &str,
265    path: &str,
266    args: &[String],
267) -> String {
268    if args.is_empty() {
269        return format!("{}.{}({})", base, method, path);
270    }
271    
272    if args.len() == 1 {
273        let migrated_handler = migrate_handler(&args[0]);
274        return format!("{}.{}({}, {})", base, method, path, migrated_handler);
275    }
276    
277    let middlewares = &args[0..args.len() - 1];
278    let handler = &args[args.len() - 1];
279    let migrated_handler = migrate_handler(handler);
280    
281    format!(
282        "{}.{}({}, {{ preHandler: [{}] }}, {})",
283        base,
284        method,
285        path,
286        middlewares.join(", "),
287        migrated_handler
288    )
289}
290
291fn migrate_use_call(
292    base: &str,
293    args: &[String],
294) -> String {
295    if args.is_empty() {
296        return format!("{}.register()", base);
297    }
298    
299    if args.len() == 1 {
300        let mw = &args[0];
301        let mw_ref = if mw.ends_with("()") {
302            &mw[..mw.len() - 2]
303        } else {
304            mw
305        };
306        return format!("{}.register({})", base, mw_ref);
307    }
308    
309    let path = &args[0];
310    let last_arg = &args[args.len() - 1];
311    let middlewares = &args[1..args.len() - 1];
312    
313    if middlewares.is_empty() {
314        return format!("{}.register({}, {{ prefix: {} }})", base, last_arg, path);
315    }
316    
317    let mw_refs: Vec<String> = middlewares.iter().map(|mw| {
318        if mw.ends_with("()") {
319            mw[..mw.len() - 2].to_string()
320        } else {
321            mw.clone()
322        }
323    }).collect();
324    
325    format!(
326        "{}.register({}, {{ prefix: {}, preHandler: [{}] }})",
327        base,
328        last_arg,
329        path,
330        mw_refs.join(", ")
331    )
332}
333
334impl TransformVisitor {
335    fn new(cm: Lrc<SourceMap>) -> Self {
336        Self {
337            changes: Vec::new(),
338            warnings: Vec::new(),
339            unsupported: Vec::new(),
340            stats: TransformStats::default(),
341            cm,
342            processed_spans: Vec::new(),
343        }
344    }
345
346    fn add_express_init(&mut self) {
347        if !self
348            .changes
349            .iter()
350            .any(|c| matches!(c, TransformChange::ExpressInit))
351        {
352            self.changes.push(TransformChange::ExpressInit);
353            self.stats.express_init_count += 1;
354        }
355    }
356
357    fn check_express_helpers(&mut self, method_name: &str) {
358        match method_name {
359            "download" => {
360                self.warnings.push("res.download() is Express-specific. Use reply.send() or @fastify/static instead.".to_string());
361            }
362            "sendFile" => {
363                self.warnings.push("res.sendFile() is Express-specific. Use @fastify/static to serve static files.".to_string());
364            }
365            "render" => {
366                self.warnings.push("res.render() is Express-specific. Use @fastify/view to render templates.".to_string());
367            }
368            "redirect" => {
369                self.warnings.push("res.redirect() is Express-specific. Use reply.redirect() instead.".to_string());
370            }
371            "cookie" => {
372                self.warnings.push("res.cookie() is Express-specific. Use @fastify/cookie to set cookies.".to_string());
373            }
374            "clearCookie" => {
375                self.warnings.push("res.clearCookie() is Express-specific. Use @fastify/cookie to clear cookies.".to_string());
376            }
377            "attachment" => {
378                self.warnings.push("res.attachment() is Express-specific. Use custom reply headers instead.".to_string());
379            }
380            "format" => {
381                self.warnings.push("res.format() is Express-specific. Perform content negotiation manually in Fastify.".to_string());
382            }
383            "links" => {
384                self.warnings.push("res.links() is Express-specific. Set the Link header manually.".to_string());
385            }
386            "location" => {
387                self.warnings.push("res.location() is Express-specific. Use reply.redirect() or set Location header manually.".to_string());
388            }
389            "vary" => {
390                self.warnings.push("res.vary() is Express-specific. Use reply.header('Vary', ...) instead.".to_string());
391            }
392            "type" | "contentType" => {
393                self.warnings.push("res.type() is Express-specific. Use reply.type() instead.".to_string());
394            }
395            "append" => {
396                self.warnings.push("res.append() is Express-specific. Use reply.header() instead.".to_string());
397            }
398            "set" | "header" => {
399                self.warnings.push("res.set() / res.header() is Express-specific. Use reply.header() instead.".to_string());
400            }
401            "get" => {
402                self.warnings.push("res.get() is Express-specific. Use reply.getHeader() instead.".to_string());
403            }
404            "locals" => {
405                self.warnings.push("res.locals is Express-specific. Use request/reply decorators instead.".to_string());
406            }
407            "write" => {
408                self.warnings.push("res.write() is Express-specific low-level stream. Fastify handles streams by returning them from the handler.".to_string());
409            }
410            "end" => {
411                self.warnings.push("res.end() is Express-specific low-level stream. Use reply.send() instead.".to_string());
412            }
413            _ => {}
414        }
415    }
416
417    fn check_middleware_warnings(&mut self, middleware_src: &str) {
418        let lower = middleware_src.to_lowercase();
419        if lower.contains("passport") {
420            self.unsupported.push("passport - use @fastify/passport instead".to_string());
421        } else if lower.contains("session") {
422            self.warnings.push("session - use @fastify/session instead".to_string());
423        } else if lower.contains("multer") {
424            self.unsupported.push("multer - use @fastify/multipart instead".to_string());
425        } else if lower.contains("csrf") {
426            self.warnings.push("csrf - use @fastify/csrf-protection instead".to_string());
427        } else if lower.contains("helmet") {
428            self.warnings.push("helmet - use @fastify/helmet instead".to_string());
429        } else if lower.contains("cors") {
430            self.warnings.push("cors - use @fastify/cors instead".to_string());
431        } else if lower.contains("morgan") {
432            self.warnings.push("morgan - use fastify's built-in logger instead".to_string());
433        } else if lower.contains("cookieparser") || lower.contains("cookie-parser") {
434            self.warnings.push("cookie-parser - use @fastify/cookie instead".to_string());
435        } else if lower.contains("compression") {
436            self.warnings.push("compression - use @fastify/compress instead".to_string());
437        } else if lower.contains("body-parser") || lower.contains("bodyparser") {
438            self.warnings.push("body-parser - Fastify parses JSON bodies by default. For URL-encoded forms, use @fastify/formbody.".to_string());
439        } else if lower.contains("cookie-session") || lower.contains("cookiesession") {
440            self.warnings.push("cookie-session - use @fastify/session or @fastify/secure-session instead.".to_string());
441        } else if lower.contains("express-validator") || lower.contains("expressvalidator") {
442            self.warnings.push("express-validator - use fastify's built-in schema validation (AJV) instead.".to_string());
443        } else if lower.contains("serve-static") || lower.contains("servestatic") {
444            self.warnings.push("serve-static - use @fastify/static instead.".to_string());
445        } else if lower.contains("method-override") || lower.contains("methodoverride") {
446            self.warnings.push("method-override - use @fastify/method-override instead.".to_string());
447        } else if lower.contains("connect-flash") || lower.contains("flash") {
448            self.warnings.push("connect-flash - use @fastify/flash instead.".to_string());
449        }
450    }
451}
452
453impl Visit for TransformVisitor {
454    fn visit_import_decl(&mut self, import: &ImportDecl) {
455        let src_str = import.src.value.to_string();
456        if src_str.contains("express") && !src_str.contains("fastify") {
457            if src_str.contains("socket.io") || src_str.contains("ws") {
458                self.unsupported
459                    .push("socket.io/ws - manual migration required".to_string());
460            } else {
461                self.changes.push(TransformChange::ImportMigration {
462                    _old: src_str.clone(),
463                    new: "fastify".to_string(),
464                });
465                self.add_express_init();
466            }
467        }
468    }
469
470    fn visit_call_expr(&mut self, call: &CallExpr) {
471        if let Some(chain) = extract_route_chain(call, &self.cm) {
472            let span = call.span();
473            if !self.processed_spans.iter().any(|ps| ps.contains(span)) {
474                self.processed_spans.push(span);
475                
476                let mut replacement = String::new();
477                for (method, args) in &chain.calls {
478                    let migrated_call = migrate_route_call(&chain.base_obj, method, &chain.path, args);
479                    replacement.push_str(&migrated_call);
480                    replacement.push_str(";\n");
481                }
482                
483                let original = self.cm.span_to_snippet(span).unwrap_or_default();
484                self.changes.push(TransformChange::Replacement {
485                    original,
486                    replacement,
487                });
488                self.stats.route_count += chain.calls.len();
489                
490                for (_method, args) in &chain.calls {
491                    if args.iter().any(|arg| arg.contains("async ")) {
492                        self.stats.async_handlers += 1;
493                    }
494                }
495            }
496            call.visit_children_with(self);
497            return;
498        }
499
500        if let Callee::Expr(expr) = &call.callee {
501            match expr.as_ref() {
502                Expr::Ident(i) if i.sym.as_ref() == "express" => {
503                    self.add_express_init();
504                }
505                Expr::Member(MemberExpr { obj, prop, .. }) => {
506                    if let Expr::Ident(i) = obj.as_ref() {
507                        let obj_name = i.sym.as_ref();
508                        if let MemberProp::Ident(p) = prop {
509                            let method = p.sym.as_ref();
510                            if method == "Router" && (obj_name == "express" || obj_name == "Router")
511                            {
512                                self.changes.push(TransformChange::RouterInit);
513                                self.stats.router_count += 1;
514                            } else if ["get", "post", "put", "delete", "patch", "head", "options"]
515                                .contains(&method)
516                            {
517                                if obj_name == "app" || obj_name == "router" || obj_name == "server" || obj_name == "api" {
518                                    let span = call.span();
519                                    if !self.processed_spans.iter().any(|ps| ps.contains(span)) {
520                                        self.processed_spans.push(span);
521                                        
522                                        let original = self.cm.span_to_snippet(span).unwrap_or_default();
523                                        let args: Vec<String> = call.args.iter().map(|arg| {
524                                            self.cm.span_to_snippet(arg.span()).unwrap_or_default()
525                                        }).collect();
526                                        
527                                        if !args.is_empty() {
528                                            let path = &args[0];
529                                            let rest_args = &args[1..];
530                                            let replacement = migrate_route_call(obj_name, method, path, rest_args);
531                                            
532                                            self.changes.push(TransformChange::Replacement {
533                                                original,
534                                                replacement,
535                                            });
536                                            self.stats.route_count += 1;
537                                            
538                                            if let Some(last) = call.args.last() {
539                                                if self.is_async(&last.expr) {
540                                                    self.stats.async_handlers += 1;
541                                                }
542                                            }
543                                        }
544                                    }
545                                }
546                            } else if method == "use" && (obj_name == "app" || obj_name == "router" || obj_name == "server" || obj_name == "api")
547                            {
548                                let span = call.span();
549                                if !self.processed_spans.iter().any(|ps| ps.contains(span)) {
550                                    self.processed_spans.push(span);
551                                    
552                                    let original = self.cm.span_to_snippet(span).unwrap_or_default();
553                                    let args: Vec<String> = call.args.iter().map(|arg| {
554                                        self.cm.span_to_snippet(arg.span()).unwrap_or_default()
555                                    }).collect();
556                                    
557                                    for arg in &args {
558                                        self.check_middleware_warnings(arg);
559                                    }
560                                    
561                                    let replacement = migrate_use_call(obj_name, &args);
562                                    self.changes.push(TransformChange::Replacement {
563                                        original,
564                                        replacement,
565                                    });
566                                    self.stats.middleware_count += 1;
567                                }
568                            }
569                        }
570                    }
571                }
572                _ => {}
573            }
574        }
575        call.visit_children_with(self);
576    }
577
578    fn visit_member_expr(&mut self, expr: &MemberExpr) {
579        if let MemberProp::Ident(p) = &expr.prop {
580            let prop = p.sym.as_ref();
581            if let Expr::Ident(i) = expr.obj.as_ref() {
582                let obj_name = i.sym.as_ref();
583                if obj_name == "res" || obj_name == "response" {
584                    if prop == "json" {
585                        self.stats.res_json_calls += 1;
586                    } else if prop == "send" {
587                        self.stats.res_send_calls += 1;
588                    }
589                    self.check_express_helpers(prop);
590                } else if obj_name == "req" || obj_name == "request" {
591                    if prop == "path" {
592                        self.warnings.push("req.path is Express-specific. Use req.routerPath or req.url instead.".to_string());
593                    } else if prop == "xhr" {
594                        self.warnings.push("req.xhr is Express-specific. Check req.headers['x-requested-with'] instead.".to_string());
595                    }
596                }
597            }
598        }
599        expr.visit_children_with(self);
600    }
601
602    fn visit_assign_expr(&mut self, expr: &AssignExpr) {
603        if let AssignTarget::Simple(simple) = &expr.left {
604            if let SimpleAssignTarget::Member(member) = simple {
605                if let Expr::Ident(i) = member.obj.as_ref() {
606                    let name = i.sym.as_ref();
607                    if name == "req" || name == "request" || name == "res" || name == "response" {
608                        if let MemberProp::Ident(p) = &member.prop {
609                            let prop = p.sym.as_ref();
610                            if !["session", "user", "body", "query", "params", "headers"].contains(&prop) {
611                                self.warnings.push(format!(
612                                    "Unsafe route mutation: assigning directly to {}.{} is discouraged in Fastify. Use decorators instead.",
613                                    name, prop
614                                ));
615                            }
616                        }
617                    }
618                }
619            }
620        }
621        expr.visit_children_with(self);
622    }
623}
624
625impl TransformVisitor {
626    fn is_async(&self, expr: &Expr) -> bool {
627        if let Expr::Arrow(arrow) = expr {
628            return arrow.is_async;
629        }
630        if let Expr::Fn(f) = expr {
631            return f.function.is_async;
632        }
633        false
634    }
635}
636
637#[derive(Debug, Clone)]
638pub struct TransformOutcome {
639    pub transformed: String,
640    pub changed: bool,
641    pub warnings: Vec<String>,
642    pub unsupported: Vec<String>,
643    pub stats: TransformStats,
644}
645
646impl TransformOutcome {
647    fn unsupported(reason: String) -> Self {
648        Self {
649            transformed: String::new(),
650            changed: false,
651            warnings: vec![],
652            unsupported: vec![reason],
653            stats: TransformStats::default(),
654        }
655    }
656
657    pub fn confidence_summary(&self) -> String {
658        let supported = self.stats.express_init_count + self.stats.route_count;
659        let total = supported + self.unsupported.len();
660        if total == 0 {
661            "No Express patterns detected".to_string()
662        } else {
663            format!("Confidence: {}/{} transforms supported", supported, total)
664        }
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn test_express_to_fastify_basic() {
674        let source = r#"const express = require('express');
675const app = express();
676app.get('/', (req, res) => res.json({ ok: true }));
677app.listen(3000);"#;
678        let mut transform = ExpressToFastifyTransform::new();
679        let result = transform.transform_source(source, std::path::Path::new("test.js"));
680        assert!(result.changed);
681        assert!(result.transformed.contains("fastify()"));
682    }
683
684    #[test]
685    fn test_express_import() {
686        let source = r#"import express from 'express';
687const app = express();"#;
688        let mut transform = ExpressToFastifyTransform::new();
689        let result = transform.transform_source(source, std::path::Path::new("test.js"));
690        assert!(result.changed);
691        assert!(result.transformed.contains("fastify"));
692    }
693
694    #[test]
695    fn test_router_init() {
696        let source = r#"const router = express.Router();"#;
697        let mut transform = ExpressToFastifyTransform::new();
698        let result = transform.transform_source(source, std::path::Path::new("test.js"));
699        assert!(result.changed);
700    }
701
702    #[test]
703    fn test_all_http_methods() {
704        let source = "app.get('/users', handler); app.post('/users', h);";
705        let mut transform = ExpressToFastifyTransform::new();
706        let result = transform.transform_source(source, std::path::Path::new("test.js"));
707        assert!(result.changed);
708        assert!(result.stats.route_count >= 2);
709    }
710
711    #[test]
712    fn test_async_handler() {
713        let source = r#"app.get('/async', async (req, res) => {});"#;
714        let mut transform = ExpressToFastifyTransform::new();
715        let result = transform.transform_source(source, std::path::Path::new("test.js"));
716        assert_eq!(result.stats.async_handlers, 1);
717    }
718
719    #[test]
720    fn test_no_change_for_non_express() {
721        let source = "const x = 1;";
722        let mut transform = ExpressToFastifyTransform::new();
723        let result = transform.transform_source(source, std::path::Path::new("test.js"));
724        assert!(!result.changed);
725    }
726
727    #[test]
728    fn test_middleware() {
729        let source = "app.use(cors());";
730        let mut transform = ExpressToFastifyTransform::new();
731        let result = transform.transform_source(source, std::path::Path::new("test.js"));
732        assert!(result.stats.middleware_count >= 1);
733    }
734
735    #[test]
736    fn test_res_json() {
737        let source = "res.json({ ok: true });";
738        let mut transform = ExpressToFastifyTransform::new();
739        let result = transform.transform_source(source, std::path::Path::new("test.js"));
740        assert_eq!(result.stats.res_json_calls, 1);
741    }
742
743    #[test]
744    fn test_res_send() {
745        let source = "res.send('hello');";
746        let mut transform = ExpressToFastifyTransform::new();
747        let result = transform.transform_source(source, std::path::Path::new("test.js"));
748        assert_eq!(result.stats.res_send_calls, 1);
749    }
750
751    #[test]
752    fn test_confidence_summary() {
753        let source = "const app = express(); app.get('/', h);";
754        let mut transform = ExpressToFastifyTransform::new();
755        let result = transform.transform_source(source, std::path::Path::new("test.js"));
756        let summary = result.confidence_summary();
757        assert!(summary.contains("Confidence"));
758    }
759
760    #[test]
761    fn test_typescript() {
762        let source = r#"import express, { Application } from 'express';
763const app: Application = express();"#;
764        let mut transform = ExpressToFastifyTransform::new();
765        let result = transform.transform_source(source, std::path::Path::new("test.ts"));
766        assert!(result.changed);
767    }
768
769    #[test]
770    fn test_router_chaining() {
771        let source = "router.route('/users').get(h1).post(h2);";
772        let mut transform = ExpressToFastifyTransform::new();
773        let result = transform.transform_source(source, std::path::Path::new("test.js"));
774        assert!(result.changed);
775        assert!(result.transformed.contains("router.get('/users', h1);"));
776        assert!(result.transformed.contains("router.post('/users', h2);"));
777    }
778
779    #[test]
780    fn test_router_chaining_middleware() {
781        let source = "router.route('/users').get(m1, h1).post(m2, m3, h2);";
782        let mut transform = ExpressToFastifyTransform::new();
783        let result = transform.transform_source(source, std::path::Path::new("test.js"));
784        assert!(result.changed);
785        assert!(result.transformed.contains("router.get('/users', { preHandler: [m1] }, h1);"));
786        assert!(result.transformed.contains("router.post('/users', { preHandler: [m2, m3] }, h2);"));
787    }
788
789    #[test]
790    fn test_simple_middleware_and_handler() {
791        let source = "app.get('/users', m1, m2, (req, res) => { res.status(200).json({ ok: true }); });";
792        let mut transform = ExpressToFastifyTransform::new();
793        let result = transform.transform_source(source, std::path::Path::new("test.js"));
794        assert!(result.changed);
795        assert!(result.transformed.contains("{ preHandler: [m1, m2] }"));
796        assert!(result.transformed.contains("(req, reply)"));
797        assert!(result.transformed.contains("reply.status(200).send({ ok: true })"));
798    }
799
800    #[test]
801    fn test_unsafe_route_mutations() {
802        let source = "app.get('/', (req, res) => { req.customProp = 'unsafe'; });";
803        let mut transform = ExpressToFastifyTransform::new();
804        let result = transform.transform_source(source, std::path::Path::new("test.js"));
805        assert!(!result.warnings.is_empty());
806        assert!(result.warnings[0].contains("Unsafe route mutation"));
807    }
808
809    #[test]
810    fn test_query_params_access() {
811        let source = "app.get('/', (req, res) => { const name = req.param('name'); const id = req.query.id; });";
812        let mut transform = ExpressToFastifyTransform::new();
813        let result = transform.transform_source(source, std::path::Path::new("test.js"));
814        assert!(result.changed);
815        assert!(result.transformed.contains("req.params.name"));
816        assert!(result.transformed.contains("req.query.id"));
817    }
818}