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 let _ = dotenvy::dotenv();
216
217 let backend_host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
219
220 let backend_port = if port != 8080 {
222 port
224 } else {
225 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 frontend_port
235 } else {
236 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 std::env::set_var("VITE_DEV_SERVER", format!("http://localhost:{vite_port}"));
255
256 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 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 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 eprintln!(
306 "{} Failed to generate types: {} (continuing anyway)",
307 style("Warning:").yellow(),
308 e
309 );
310 }
311 }
312 println!();
313 }
314
315 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 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 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 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 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 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 while !manager.shutdown.load(Ordering::SeqCst) {
411 thread::sleep(std::time::Duration::from_millis(100));
412
413 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
424fn 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 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 match rx.recv_timeout(Duration::from_millis(100)) {
478 Ok(event) => {
479 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(_) => {} 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}