Skip to main content

ferro_cli/commands/
serve.rs

1use super::clean;
2use console::style;
3use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
4use std::io::{BufRead, BufReader};
5use std::net::TcpListener;
6use std::path::Path;
7use std::process::{Child, Command, Stdio};
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::mpsc::channel;
10use std::sync::Arc;
11use std::thread;
12use std::time::Duration;
13
14struct ProcessManager {
15    children: Vec<Child>,
16    shutdown: Arc<AtomicBool>,
17}
18
19impl ProcessManager {
20    fn new() -> Self {
21        Self {
22            children: Vec::new(),
23            shutdown: Arc::new(AtomicBool::new(false)),
24        }
25    }
26
27    fn spawn_with_prefix(
28        &mut self,
29        command: &str,
30        args: &[&str],
31        cwd: Option<&Path>,
32        prefix: &str,
33        color: console::Color,
34    ) -> Result<(), String> {
35        self.spawn_with_prefix_env(command, args, cwd, prefix, color, &[])
36    }
37
38    fn spawn_with_prefix_env(
39        &mut self,
40        command: &str,
41        args: &[&str],
42        cwd: Option<&Path>,
43        prefix: &str,
44        color: console::Color,
45        env_vars: &[(&str, &str)],
46    ) -> Result<(), String> {
47        let mut cmd = Command::new(command);
48        cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
49
50        for (key, value) in env_vars {
51            cmd.env(key, value);
52        }
53
54        if let Some(dir) = cwd {
55            cmd.current_dir(dir);
56        }
57
58        let mut child = cmd
59            .spawn()
60            .map_err(|e| format!("Failed to spawn {command}: {e}"))?;
61
62        let stdout = child.stdout.take().unwrap();
63        let stderr = child.stderr.take().unwrap();
64        let shutdown_stdout = self.shutdown.clone();
65        let shutdown_stderr = self.shutdown.clone();
66
67        let prefix_out = prefix.to_string();
68        let prefix_err = prefix.to_string();
69
70        thread::spawn(move || {
71            let reader = BufReader::new(stdout);
72            for line in reader.lines() {
73                if shutdown_stdout.load(Ordering::SeqCst) {
74                    break;
75                }
76                if let Ok(line) = line {
77                    println!("{} {}", style(&prefix_out).fg(color).bold(), line);
78                }
79            }
80        });
81
82        thread::spawn(move || {
83            let reader = BufReader::new(stderr);
84            for line in reader.lines() {
85                if shutdown_stderr.load(Ordering::SeqCst) {
86                    break;
87                }
88                if let Ok(line) = line {
89                    eprintln!("{} {}", style(&prefix_err).fg(color).bold(), line);
90                }
91            }
92        });
93
94        self.children.push(child);
95        Ok(())
96    }
97
98    fn shutdown_all(&mut self) {
99        self.shutdown.store(true, Ordering::SeqCst);
100        for child in &mut self.children {
101            let _ = child.kill();
102            let _ = child.wait();
103        }
104    }
105
106    fn any_exited(&mut self) -> bool {
107        for child in &mut self.children {
108            if let Ok(Some(_)) = child.try_wait() {
109                return true;
110            }
111        }
112        false
113    }
114}
115
116fn get_package_name() -> Result<String, String> {
117    let cargo_toml = Path::new("Cargo.toml");
118    let content = std::fs::read_to_string(cargo_toml)
119        .map_err(|e| format!("Failed to read Cargo.toml: {e}"))?;
120
121    let parsed: toml::Value = content
122        .parse()
123        .map_err(|e| format!("Failed to parse Cargo.toml: {e}"))?;
124
125    parsed
126        .get("package")
127        .and_then(|p| p.get("name"))
128        .and_then(|n| n.as_str())
129        .map(|s| s.to_string())
130        .ok_or_else(|| "Could not find package name in Cargo.toml".to_string())
131}
132
133fn validate_ferro_project(backend_only: bool, frontend_only: bool) -> Result<(), String> {
134    let cargo_toml = Path::new("Cargo.toml");
135    let frontend_dir = Path::new("frontend");
136
137    if !frontend_only && !cargo_toml.exists() {
138        return Err("No Cargo.toml found. Are you in a Ferro project directory?".into());
139    }
140
141    if !backend_only && !frontend_dir.exists() {
142        return Err("No frontend directory found. Are you in a Ferro project directory?".into());
143    }
144
145    Ok(())
146}
147
148fn ensure_cargo_watch() -> Result<(), String> {
149    let status = Command::new("cargo")
150        .args(["watch", "--version"])
151        .stdout(Stdio::null())
152        .stderr(Stdio::null())
153        .status();
154
155    match status {
156        Ok(s) if s.success() => Ok(()),
157        _ => {
158            println!("{}", style("cargo-watch not found. Installing...").yellow());
159            let install = Command::new("cargo")
160                .args(["install", "cargo-watch"])
161                .status()
162                .map_err(|e| format!("Failed to install cargo-watch: {e}"))?;
163
164            if !install.success() {
165                return Err("Failed to install cargo-watch".into());
166            }
167            println!("{}", style("cargo-watch installed successfully.").green());
168            Ok(())
169        }
170    }
171}
172
173fn ensure_npm_dependencies() -> Result<(), String> {
174    let frontend_path = Path::new("frontend");
175    let node_modules = frontend_path.join("node_modules");
176
177    if !node_modules.exists() {
178        println!("{}", style("Installing frontend dependencies...").yellow());
179        let npm_install = Command::new("npm")
180            .args(["install"])
181            .current_dir(frontend_path)
182            .status()
183            .map_err(|e| format!("Failed to run npm install: {e}"))?;
184
185        if !npm_install.success() {
186            return Err("Failed to install npm dependencies".into());
187        }
188        println!(
189            "{}",
190            style("Frontend dependencies installed successfully.").green()
191        );
192    }
193
194    Ok(())
195}
196
197fn find_available_port(start: u16, max_attempts: u16) -> u16 {
198    for offset in 0..max_attempts {
199        let port = start + offset;
200        if TcpListener::bind(("127.0.0.1", port)).is_ok() {
201            return port;
202        }
203    }
204    start
205}
206
207pub fn run(
208    port: u16,
209    frontend_port: u16,
210    backend_only: bool,
211    frontend_only: bool,
212    skip_types: bool,
213) {
214    // Load .env file from current directory
215    let _ = dotenvy::dotenv();
216
217    // Resolve backend host and port from env vars (matching ServerConfig defaults)
218    let backend_host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
219
220    // Resolve ports: CLI args take precedence, then env vars, then defaults
221    let backend_port = if port != 8080 {
222        // CLI argument was explicitly provided (different from default)
223        port
224    } else {
225        // Use env var or default (8080)
226        std::env::var("SERVER_PORT")
227            .ok()
228            .and_then(|v| v.parse().ok())
229            .unwrap_or(8080)
230    };
231
232    let requested_vite_port = if frontend_port != 5173 {
233        // CLI argument was explicitly provided
234        frontend_port
235    } else {
236        // Use env var or default
237        std::env::var("VITE_PORT")
238            .ok()
239            .and_then(|v| v.parse().ok())
240            .unwrap_or(frontend_port)
241    };
242
243    let vite_port = find_available_port(requested_vite_port, 10);
244    if vite_port != requested_vite_port {
245        println!(
246            "{} Port {} in use, using {} instead",
247            style("[frontend]").cyan().bold(),
248            requested_vite_port,
249            vite_port
250        );
251    }
252
253    // Set VITE_DEV_SERVER so InertiaConfig picks up the resolved port
254    std::env::set_var("VITE_DEV_SERVER", format!("http://localhost:{vite_port}"));
255
256    // Auto-cleanup old build artifacts (silent, non-blocking)
257    // Configurable via CARGO_SWEEP_DAYS (default: 7, set to 0 to disable)
258    let sweep_days: u32 = std::env::var("CARGO_SWEEP_DAYS")
259        .ok()
260        .and_then(|v| v.parse().ok())
261        .unwrap_or(7);
262
263    if sweep_days > 0 {
264        if let Some(cleaned) = clean::run_silent(sweep_days) {
265            println!("{} {}", style("♻").cyan(), cleaned);
266        }
267    }
268
269    println!();
270    println!(
271        "{}",
272        style("Starting Ferro development servers...").cyan().bold()
273    );
274    println!();
275
276    // Validate project
277    if let Err(e) = validate_ferro_project(backend_only, frontend_only) {
278        eprintln!("{} {}", style("Error:").red().bold(), e);
279        std::process::exit(1);
280    }
281
282    // Generate TypeScript types on startup (unless skipped or frontend-only)
283    if !skip_types && !frontend_only {
284        let project_path = Path::new(".");
285        let output_path = project_path.join("frontend/src/types/inertia-props.ts");
286
287        println!("{}", style("Generating TypeScript types...").cyan());
288        match super::generate_types::generate_types_to_file(project_path, &output_path) {
289            Ok(0) => {
290                println!(
291                    "{}",
292                    style("No InertiaProps structs found (skipping type generation)").dim()
293                );
294            }
295            Ok(count) => {
296                println!(
297                    "{} Generated {} type(s) to {}",
298                    style("✓").green(),
299                    count,
300                    output_path.display()
301                );
302            }
303            Err(e) => {
304                // Don't fail, just warn - types are a nice-to-have
305                eprintln!(
306                    "{} Failed to generate types: {} (continuing anyway)",
307                    style("Warning:").yellow(),
308                    e
309                );
310            }
311        }
312        println!();
313    }
314
315    // Ensure cargo-watch is installed (only if running backend)
316    if !frontend_only {
317        if let Err(e) = ensure_cargo_watch() {
318            eprintln!("{} {}", style("Error:").red().bold(), e);
319            std::process::exit(1);
320        }
321    }
322
323    // Ensure npm dependencies are installed (only if running frontend)
324    if !backend_only {
325        if let Err(e) = ensure_npm_dependencies() {
326            eprintln!("{} {}", style("Error:").red().bold(), e);
327            std::process::exit(1);
328        }
329    }
330
331    let mut manager = ProcessManager::new();
332    let shutdown = manager.shutdown.clone();
333
334    // Set up Ctrl+C handler
335    ctrlc::set_handler(move || {
336        println!();
337        println!("{}", style("Shutting down servers...").yellow());
338        shutdown.store(true, Ordering::SeqCst);
339    })
340    .expect("Error setting Ctrl-C handler");
341
342    // Start backend with cargo-watch
343    if !frontend_only {
344        let package_name = match get_package_name() {
345            Ok(name) => name,
346            Err(e) => {
347                eprintln!("{} {}", style("Error:").red().bold(), e);
348                std::process::exit(1);
349            }
350        };
351
352        println!(
353            "{} Backend server on http://{}:{}",
354            style("[backend]").magenta().bold(),
355            backend_host,
356            backend_port
357        );
358
359        let run_cmd = format!("run --bin {package_name}");
360        if let Err(e) = manager.spawn_with_prefix(
361            "cargo",
362            &["watch", "-x", &run_cmd],
363            None,
364            "[backend] ",
365            console::Color::Magenta,
366        ) {
367            eprintln!("{} {}", style("Error:").red().bold(), e);
368            std::process::exit(1);
369        }
370    }
371
372    // Start frontend with npm/vite
373    if !backend_only {
374        println!(
375            "{} Frontend server on http://127.0.0.1:{}",
376            style("[frontend]").cyan().bold(),
377            vite_port
378        );
379
380        let frontend_path = Path::new("frontend");
381        let vite_port_str = vite_port.to_string();
382
383        if let Err(e) = manager.spawn_with_prefix_env(
384            "npm",
385            &["run", "dev", "--", "--port", &vite_port_str, "--strictPort"],
386            Some(frontend_path),
387            "[frontend]",
388            console::Color::Cyan,
389            &[],
390        ) {
391            eprintln!("{} {}", style("Error:").red().bold(), e);
392            manager.shutdown_all();
393            std::process::exit(1);
394        }
395    }
396
397    // Start file watcher for TypeScript type regeneration
398    if !skip_types && !frontend_only {
399        let shutdown_watcher = manager.shutdown.clone();
400        thread::spawn(move || {
401            start_type_watcher(shutdown_watcher);
402        });
403    }
404
405    println!();
406    println!("{}", style("Press Ctrl+C to stop all servers").dim());
407    println!();
408
409    // Wait for shutdown signal or process exit
410    while !manager.shutdown.load(Ordering::SeqCst) {
411        thread::sleep(std::time::Duration::from_millis(100));
412
413        // Check if any child process has exited
414        if manager.any_exited() {
415            manager.shutdown.store(true, Ordering::SeqCst);
416            break;
417        }
418    }
419
420    manager.shutdown_all();
421    println!("{}", style("Servers stopped.").green());
422}
423
424/// File watcher that regenerates TypeScript types when Rust files change
425fn start_type_watcher(shutdown: Arc<AtomicBool>) {
426    let (tx, rx) = channel();
427    let src_path = Path::new("src");
428
429    let watcher_result = RecommendedWatcher::new(
430        move |res| {
431            if let Ok(event) = res {
432                let _ = tx.send(event);
433            }
434        },
435        Config::default().with_poll_interval(Duration::from_secs(2)),
436    );
437
438    let mut watcher = match watcher_result {
439        Ok(w) => w,
440        Err(e) => {
441            eprintln!(
442                "{} Failed to start type watcher: {}",
443                style("[types]").yellow(),
444                e
445            );
446            return;
447        }
448    };
449
450    if let Err(e) = watcher.watch(src_path, RecursiveMode::Recursive) {
451        eprintln!(
452            "{} Failed to watch src directory: {}",
453            style("[types]").yellow(),
454            e
455        );
456        return;
457    }
458
459    println!(
460        "{} Watching for Rust file changes to regenerate types",
461        style("[types]").blue()
462    );
463
464    let project_path = Path::new(".");
465    let output_path = project_path.join("frontend/src/types/inertia-props.ts");
466
467    // Debounce timer to avoid regenerating too frequently
468    let mut last_regen = std::time::Instant::now();
469    let debounce_duration = Duration::from_millis(500);
470
471    loop {
472        if shutdown.load(Ordering::SeqCst) {
473            break;
474        }
475
476        // Use recv_timeout to periodically check shutdown
477        match rx.recv_timeout(Duration::from_millis(100)) {
478            Ok(event) => {
479                // Check if it's a Rust file change
480                let is_rust_change = event
481                    .paths
482                    .iter()
483                    .any(|p| p.extension().map(|e| e == "rs").unwrap_or(false));
484
485                if is_rust_change && last_regen.elapsed() > debounce_duration {
486                    last_regen = std::time::Instant::now();
487
488                    match super::generate_types::generate_types_to_file(project_path, &output_path)
489                    {
490                        Ok(count) if count > 0 => {
491                            println!("{} Regenerated {} type(s)", style("[types]").blue(), count);
492                        }
493                        Ok(_) => {} // No types found, stay quiet
494                        Err(e) => {
495                            eprintln!("{} Failed to regenerate: {}", style("[types]").yellow(), e);
496                        }
497                    }
498                }
499            }
500            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
501            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
502        }
503    }
504}