1use super::clean;
2use console::style;
3use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
4use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
5use notify::RecursiveMode;
6use notify_debouncer_mini::{new_debouncer, DebouncedEvent};
7use std::io::{self, BufRead, BufReader, IsTerminal, Write};
8use std::net::TcpListener;
9use std::path::{Path, PathBuf};
10use std::process::{Child, Command, Stdio};
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::mpsc::{channel, Receiver, RecvTimeoutError, Sender};
13use std::sync::Arc;
14use std::thread::{self, JoinHandle};
15use std::time::Duration;
16
17macro_rules! sprintln {
26 () => {{
27 print!("\r\n");
28 let _ = io::stdout().flush();
29 }};
30 ($($arg:tt)*) => {{
31 print!("{}\r\n", format_args!($($arg)*));
32 let _ = io::stdout().flush();
33 }};
34}
35
36macro_rules! seprintln {
38 () => {{
39 eprint!("\r\n");
40 let _ = io::stderr().flush();
41 }};
42 ($($arg:tt)*) => {{
43 eprint!("{}\r\n", format_args!($($arg)*));
44 let _ = io::stderr().flush();
45 }};
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub(super) enum ReloadTrigger {
51 Manual,
52 FileChanged,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub(super) enum KbAction {
58 Reload,
59 Quit,
60}
61
62#[allow(clippy::too_many_arguments)]
67pub(super) fn render_banner(
68 is_watch: bool,
69 is_tty: bool,
70 backend_only: bool,
71 frontend_only: bool,
72 backend_host: &str,
73 backend_port: u16,
74 vite_port: u16,
75) -> String {
76 use std::fmt::Write;
77 let mut s = String::new();
78 if !frontend_only {
79 let _ = writeln!(s, "Backend: http://{backend_host}:{backend_port}");
80 }
81 if !backend_only {
82 let _ = writeln!(s, "Frontend: http://127.0.0.1:{vite_port}");
83 }
84 if !frontend_only {
85 let _ = writeln!(s);
86 if is_tty {
87 let _ = writeln!(s, " r rebuild backend + regenerate types");
88 } else {
89 let _ = writeln!(s, " r unavailable (non-TTY stdin)");
90 }
91 let _ = writeln!(s, " q quit (or Ctrl+C)");
92 if is_watch {
93 let _ = writeln!(s, " watch enabled (debounce 500ms)");
94 } else {
95 let _ = writeln!(
96 s,
97 " watch disabled (pass --watch to auto-reload on file changes)"
98 );
99 }
100 }
101 s
102}
103
104pub(super) fn classify_key(code: KeyCode, modifiers: KeyModifiers) -> Option<KbAction> {
107 match (code, modifiers) {
108 (KeyCode::Char('r'), KeyModifiers::NONE) => Some(KbAction::Reload),
109 (KeyCode::Char('q'), KeyModifiers::NONE) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
110 Some(KbAction::Quit)
111 }
112 _ => None,
113 }
114}
115
116pub(super) fn format_trigger_source(t: ReloadTrigger) -> &'static str {
118 match t {
119 ReloadTrigger::Manual => "manual",
120 ReloadTrigger::FileChanged => "file change",
121 }
122}
123
124pub(super) fn should_spawn_keyboard(is_tty: bool) -> bool {
126 is_tty
127}
128
129#[cfg(unix)]
140fn configure_new_process_group(cmd: &mut Command) {
141 use std::os::unix::process::CommandExt;
142 unsafe {
147 cmd.pre_exec(|| {
148 if libc::setsid() == -1 {
149 return Err(std::io::Error::last_os_error());
150 }
151 Ok(())
152 });
153 }
154}
155
156#[cfg(not(unix))]
157fn configure_new_process_group(_cmd: &mut Command) {}
158
159fn spawn_child_with_prefix(
160 command: &str,
161 args: &[&str],
162 cwd: Option<&Path>,
163 prefix: &str,
164 color: console::Color,
165 env_vars: &[(&str, &str)],
166 shutdown: Arc<AtomicBool>,
167) -> Result<Child, String> {
168 let mut cmd = Command::new(command);
169 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
170
171 for (key, value) in env_vars {
172 cmd.env(key, value);
173 }
174
175 if let Some(dir) = cwd {
176 cmd.current_dir(dir);
177 }
178
179 configure_new_process_group(&mut cmd);
180
181 let mut child = cmd
182 .spawn()
183 .map_err(|e| format!("Failed to spawn {command}: {e}"))?;
184
185 let stdout = child.stdout.take().expect("stdout piped");
186 let stderr = child.stderr.take().expect("stderr piped");
187 let prefix_out = prefix.to_string();
188 let prefix_err = prefix.to_string();
189 let sd_out = shutdown.clone();
190 let sd_err = shutdown;
191
192 thread::spawn(move || {
193 let reader = BufReader::new(stdout);
194 for line in reader.lines() {
195 if sd_out.load(Ordering::SeqCst) {
196 break;
197 }
198 if let Ok(line) = line {
199 print!("{} {}\r\n", style(&prefix_out).fg(color).bold(), line);
205 let _ = io::stdout().flush();
206 }
207 }
208 });
209
210 thread::spawn(move || {
211 let reader = BufReader::new(stderr);
212 for line in reader.lines() {
213 if sd_err.load(Ordering::SeqCst) {
214 break;
215 }
216 if let Ok(line) = line {
217 eprint!("{} {}\r\n", style(&prefix_err).fg(color).bold(), line);
218 let _ = io::stderr().flush();
219 }
220 }
221 });
222
223 Ok(child)
224}
225
226const GROUP_KILL_GRACE: Duration = Duration::from_millis(2000);
228const GROUP_KILL_POLL: Duration = Duration::from_millis(50);
230
231fn terminate_child_group(child: &mut Child) {
240 #[cfg(unix)]
241 {
242 let pid = child.id() as i32;
243 unsafe {
245 libc::kill(-pid, libc::SIGTERM);
246 }
247 let deadline = std::time::Instant::now() + GROUP_KILL_GRACE;
248 loop {
249 match child.try_wait() {
250 Ok(Some(_)) => return,
251 Ok(None) => {
252 if std::time::Instant::now() >= deadline {
253 break;
254 }
255 thread::sleep(GROUP_KILL_POLL);
256 }
257 Err(_) => break,
258 }
259 }
260 unsafe {
261 libc::kill(-pid, libc::SIGKILL);
262 }
263 let _ = child.wait();
264 }
265 #[cfg(not(unix))]
266 {
267 let _ = child.kill();
268 let _ = child.wait();
269 }
270}
271
272struct ProcessManager {
273 children: Vec<Child>,
274 shutdown: Arc<AtomicBool>,
275}
276
277impl ProcessManager {
278 fn new() -> Self {
279 Self {
280 children: Vec::new(),
281 shutdown: Arc::new(AtomicBool::new(false)),
282 }
283 }
284
285 fn spawn_with_prefix_env(
286 &mut self,
287 command: &str,
288 args: &[&str],
289 cwd: Option<&Path>,
290 prefix: &str,
291 color: console::Color,
292 env_vars: &[(&str, &str)],
293 ) -> Result<(), String> {
294 let child = spawn_child_with_prefix(
295 command,
296 args,
297 cwd,
298 prefix,
299 color,
300 env_vars,
301 self.shutdown.clone(),
302 )?;
303 self.children.push(child);
304 Ok(())
305 }
306
307 fn shutdown_all(&mut self) {
308 self.shutdown.store(true, Ordering::SeqCst);
309 for child in &mut self.children {
310 terminate_child_group(child);
311 }
312 }
313}
314
315fn get_package_name() -> Result<String, String> {
316 let cargo_toml = Path::new("Cargo.toml");
317 let content = std::fs::read_to_string(cargo_toml)
318 .map_err(|e| format!("Failed to read Cargo.toml: {e}"))?;
319
320 let parsed: toml::Value = content
321 .parse()
322 .map_err(|e| format!("Failed to parse Cargo.toml: {e}"))?;
323
324 parsed
325 .get("package")
326 .and_then(|p| p.get("name"))
327 .and_then(|n| n.as_str())
328 .map(|s| s.to_string())
329 .ok_or_else(|| "Could not find package name in Cargo.toml".to_string())
330}
331
332fn validate_ferro_project(backend_only: bool, frontend_only: bool) -> Result<(), String> {
333 let cargo_toml = Path::new("Cargo.toml");
334 let frontend_dir = Path::new("frontend");
335
336 if !frontend_only && !cargo_toml.exists() {
337 return Err("No Cargo.toml found. Are you in a Ferro project directory?".into());
338 }
339
340 if !backend_only && !frontend_dir.exists() {
341 return Err("No frontend directory found. Are you in a Ferro project directory?".into());
342 }
343
344 Ok(())
345}
346
347fn ensure_npm_dependencies() -> Result<(), String> {
348 let frontend_path = Path::new("frontend");
349 let node_modules = frontend_path.join("node_modules");
350
351 if !node_modules.exists() {
352 sprintln!("{}", style("Installing frontend dependencies...").yellow());
353 let npm_install = Command::new("npm")
354 .args(["install"])
355 .current_dir(frontend_path)
356 .status()
357 .map_err(|e| format!("Failed to run npm install: {e}"))?;
358
359 if !npm_install.success() {
360 return Err("Failed to install npm dependencies".into());
361 }
362 sprintln!(
363 "{}",
364 style("Frontend dependencies installed successfully.").green()
365 );
366 }
367
368 Ok(())
369}
370
371fn find_available_port(start: u16, max_attempts: u16) -> u16 {
372 for offset in 0..max_attempts {
373 let port = start + offset;
374 if TcpListener::bind(("127.0.0.1", port)).is_ok() {
375 return port;
376 }
377 }
378 start
379}
380
381struct RawModeGuard;
384
385impl Drop for RawModeGuard {
386 fn drop(&mut self) {
387 let _ = disable_raw_mode();
388 }
389}
390
391fn spawn_keyboard_thread(
396 tx: Sender<ReloadTrigger>,
397 shutdown: Arc<AtomicBool>,
398) -> Option<JoinHandle<()>> {
399 let is_tty = std::io::stdin().is_terminal();
400 if !should_spawn_keyboard(is_tty) {
401 return None;
402 }
403 if let Err(e) = enable_raw_mode() {
404 seprintln!("{} raw mode unavailable: {e}", style("Warning:").yellow());
405 return None;
406 }
407 Some(thread::spawn(move || {
408 let _guard = RawModeGuard;
409 while !shutdown.load(Ordering::SeqCst) {
410 match event::poll(Duration::from_millis(100)) {
411 Ok(true) => {}
412 _ => continue,
413 }
414 let Ok(Event::Key(k)) = event::read() else {
415 continue;
416 };
417 if k.kind != KeyEventKind::Press {
419 continue;
420 }
421 match classify_key(k.code, k.modifiers) {
422 Some(KbAction::Reload) => {
423 let _ = tx.send(ReloadTrigger::Manual);
424 }
425 Some(KbAction::Quit) => {
426 shutdown.store(true, Ordering::SeqCst);
427 break;
428 }
429 None => {}
430 }
431 }
432 }))
433}
434
435fn spawn_file_watcher_at(
441 src: &Path,
442 debounce: Duration,
443 tx: Sender<ReloadTrigger>,
444) -> Option<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>> {
445 if !src.is_dir() {
446 seprintln!(
447 "{} {} missing, --watch disabled",
448 style("Warning:").yellow(),
449 src.display()
450 );
451 return None;
452 }
453 let mut debouncer = match new_debouncer(
454 debounce,
455 move |res: notify_debouncer_mini::DebounceEventResult| {
456 let Ok(events) = res else {
457 return;
458 };
459 let any_rs = events
460 .iter()
461 .any(|e: &DebouncedEvent| e.path.extension().map(|x| x == "rs").unwrap_or(false));
462 if any_rs {
463 let _ = tx.send(ReloadTrigger::FileChanged);
464 }
465 },
466 ) {
467 Ok(d) => d,
468 Err(e) => {
469 seprintln!("{} notify init failed: {e}", style("Warning:").yellow());
470 return None;
471 }
472 };
473 if let Err(e) = debouncer.watcher().watch(src, RecursiveMode::Recursive) {
474 seprintln!(
475 "{} watch({}) failed: {e}",
476 style("Warning:").yellow(),
477 src.display()
478 );
479 return None;
480 }
481 Some(debouncer)
482}
483
484fn spawn_file_watcher(
487 tx: Sender<ReloadTrigger>,
488) -> Option<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>> {
489 spawn_file_watcher_at(Path::new("src"), Duration::from_millis(500), tx)
490}
491
492struct BackendSupervisor {
496 package_name: String,
497 skip_types: bool,
498 project_path: PathBuf,
499 types_output_path: PathBuf,
500 current: Option<Child>,
501 shutdown: Arc<AtomicBool>,
502}
503
504impl BackendSupervisor {
505 fn new(
506 package_name: String,
507 skip_types: bool,
508 project_path: PathBuf,
509 types_output_path: PathBuf,
510 shutdown: Arc<AtomicBool>,
511 ) -> Self {
512 Self {
513 package_name,
514 skip_types,
515 project_path,
516 types_output_path,
517 current: None,
518 shutdown,
519 }
520 }
521
522 fn kill_current(&mut self) {
527 if let Some(mut child) = self.current.take() {
528 terminate_child_group(&mut child);
529 }
530 }
531
532 fn regenerate_types(&self) {
536 if self.skip_types {
537 return;
538 }
539 match super::generate_types::generate_types_to_file(
540 &self.project_path,
541 &self.types_output_path,
542 ) {
543 Ok(count) if count > 0 => {
544 sprintln!("{} Regenerated {} type(s)", style("[types]").blue(), count);
545 }
546 Ok(_) => {}
547 Err(e) => {
548 seprintln!("{} Failed to regenerate: {}", style("[types]").yellow(), e);
549 }
550 }
551 }
552
553 fn spawn_backend(&mut self) {
557 let args = ["run", "--bin", self.package_name.as_str()];
558 match spawn_child_with_prefix(
559 "cargo",
560 &args,
561 None,
562 "[backend]",
563 console::Color::Magenta,
564 &[],
565 self.shutdown.clone(),
566 ) {
567 Ok(child) => self.current = Some(child),
568 Err(e) => {
569 seprintln!("{} {}", style("Error:").red().bold(), e);
570 self.current = None;
571 }
572 }
573 }
574
575 fn drain_triggers(rx: &Receiver<ReloadTrigger>, initial: ReloadTrigger) -> ReloadTrigger {
580 let mut latest = initial;
581 while let Ok(next) = rx.try_recv() {
582 latest = next;
583 }
584 latest
585 }
586
587 fn run_loop(&mut self, rx: Receiver<ReloadTrigger>) {
591 self.spawn_backend();
592 loop {
593 if self.shutdown.load(Ordering::SeqCst) {
594 self.kill_current();
595 break;
596 }
597 match rx.recv_timeout(Duration::from_millis(100)) {
598 Ok(initial) => {
599 let src = Self::drain_triggers(&rx, initial);
600 sprintln!(
601 "{} reload triggered ({})",
602 style("[backend]").magenta().bold(),
603 format_trigger_source(src)
604 );
605 self.kill_current();
606 self.regenerate_types();
607 self.spawn_backend();
608 }
609 Err(RecvTimeoutError::Timeout) => continue,
610 Err(RecvTimeoutError::Disconnected) => break,
611 }
612 }
613 }
614}
615
616pub fn run(
617 port: u16,
618 frontend_port: u16,
619 backend_only: bool,
620 frontend_only: bool,
621 skip_types: bool,
622 watch: bool,
623) {
624 let _ = dotenvy::dotenv();
626
627 let backend_host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
629
630 let backend_port = if port != 8080 {
632 port
634 } else {
635 std::env::var("SERVER_PORT")
637 .ok()
638 .and_then(|v| v.parse().ok())
639 .unwrap_or(8080)
640 };
641
642 let requested_vite_port = if frontend_port != 5173 {
643 frontend_port
645 } else {
646 std::env::var("VITE_PORT")
648 .ok()
649 .and_then(|v| v.parse().ok())
650 .unwrap_or(frontend_port)
651 };
652
653 let vite_port = find_available_port(requested_vite_port, 10);
654 if vite_port != requested_vite_port {
655 sprintln!(
656 "{} Port {} in use, using {} instead",
657 style("[frontend]").cyan().bold(),
658 requested_vite_port,
659 vite_port
660 );
661 }
662
663 std::env::set_var("VITE_DEV_SERVER", format!("http://localhost:{vite_port}"));
665
666 let sweep_days: u32 = std::env::var("CARGO_SWEEP_DAYS")
669 .ok()
670 .and_then(|v| v.parse().ok())
671 .unwrap_or(7);
672
673 if sweep_days > 0 {
674 if let Some(cleaned) = clean::run_silent(sweep_days) {
675 sprintln!("{} {}", style("♻").cyan(), cleaned);
676 }
677 }
678
679 sprintln!();
680 sprintln!(
681 "{}",
682 style("Starting Ferro development servers...").cyan().bold()
683 );
684 sprintln!();
685
686 if let Err(e) = validate_ferro_project(backend_only, frontend_only) {
688 seprintln!("{} {}", style("Error:").red().bold(), e);
689 std::process::exit(1);
690 }
691
692 if !skip_types && !frontend_only {
694 let project_path = Path::new(".");
695 let output_path = project_path.join("frontend/src/types/inertia-props.ts");
696
697 sprintln!("{}", style("Generating TypeScript types...").cyan());
698 match super::generate_types::generate_types_to_file(project_path, &output_path) {
699 Ok(0) => {
700 sprintln!(
701 "{}",
702 style("No InertiaProps structs found (skipping type generation)").dim()
703 );
704 }
705 Ok(count) => {
706 sprintln!(
707 "{} Generated {} type(s) to {}",
708 style("✓").green(),
709 count,
710 output_path.display()
711 );
712 }
713 Err(e) => {
714 seprintln!(
716 "{} Failed to generate types: {} (continuing anyway)",
717 style("Warning:").yellow(),
718 e
719 );
720 }
721 }
722 sprintln!();
723 }
724
725 if !backend_only {
727 if let Err(e) = ensure_npm_dependencies() {
728 seprintln!("{} {}", style("Error:").red().bold(), e);
729 std::process::exit(1);
730 }
731 }
732
733 let mut manager = ProcessManager::new();
734 let shutdown = manager.shutdown.clone();
735
736 {
739 let shutdown = shutdown.clone();
740 ctrlc::set_handler(move || {
741 sprintln!();
742 sprintln!("{}", style("Shutting down servers...").yellow());
743 shutdown.store(true, Ordering::SeqCst);
744 })
745 .expect("Error setting Ctrl-C handler");
746 }
747
748 let is_tty = std::io::stdin().is_terminal();
751 let banner = render_banner(
752 watch,
753 is_tty,
754 backend_only,
755 frontend_only,
756 &backend_host,
757 backend_port,
758 vite_port,
759 );
760 print!("{banner}");
761
762 if !backend_only {
764 let frontend_path = Path::new("frontend");
765 let vite_port_str = vite_port.to_string();
766
767 if let Err(e) = manager.spawn_with_prefix_env(
768 "npm",
769 &["run", "dev", "--", "--port", &vite_port_str, "--strictPort"],
770 Some(frontend_path),
771 "[frontend]",
772 console::Color::Cyan,
773 &[],
774 ) {
775 seprintln!("{} {}", style("Error:").red().bold(), e);
776 manager.shutdown_all();
777 std::process::exit(1);
778 }
779 }
780
781 let supervisor_handle: Option<JoinHandle<()>>;
785 let keyboard_handle: Option<JoinHandle<()>>;
786 let _debouncer: Option<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>>;
787
788 if !frontend_only {
789 let package_name = match get_package_name() {
790 Ok(name) => name,
791 Err(e) => {
792 seprintln!("{} {}", style("Error:").red().bold(), e);
793 manager.shutdown_all();
794 std::process::exit(1);
795 }
796 };
797
798 let project_path = Path::new(".").to_path_buf();
799 let types_output_path = project_path.join("frontend/src/types/inertia-props.ts");
800
801 let (reload_tx, reload_rx) = channel::<ReloadTrigger>();
802 keyboard_handle = spawn_keyboard_thread(reload_tx.clone(), shutdown.clone());
803 _debouncer = if watch {
804 spawn_file_watcher(reload_tx.clone())
805 } else {
806 None
807 };
808
809 let mut supervisor = BackendSupervisor::new(
810 package_name,
811 skip_types,
812 project_path,
813 types_output_path,
814 shutdown.clone(),
815 );
816 supervisor_handle = Some(thread::spawn(move || supervisor.run_loop(reload_rx)));
817
818 if let Ok(pipe_path) = std::env::var("FERRO_SERVE_TEST_TRIGGER_PIPE") {
826 let tx = reload_tx.clone();
827 let sd = shutdown.clone();
828 thread::spawn(move || loop {
829 if sd.load(Ordering::SeqCst) {
830 break;
831 }
832 if let Ok(content) = std::fs::read_to_string(&pipe_path) {
833 if !content.is_empty() {
834 if content.contains('r') {
835 let _ = tx.send(ReloadTrigger::Manual);
836 }
837 if content.contains('q') {
838 sd.store(true, Ordering::SeqCst);
839 break;
840 }
841 let _ = std::fs::write(&pipe_path, "");
842 }
843 }
844 thread::sleep(Duration::from_millis(50));
845 });
846 }
847
848 drop(reload_tx);
851 } else {
852 supervisor_handle = None;
854 keyboard_handle = None;
855 _debouncer = None;
856 }
857
858 sprintln!();
859 sprintln!("{}", style("Press Ctrl+C to stop all servers").dim());
860 sprintln!();
861
862 while !shutdown.load(Ordering::SeqCst) {
866 thread::sleep(Duration::from_millis(100));
867 }
868
869 if let Some(h) = keyboard_handle {
875 let _ = h.join();
876 }
877 drop(_debouncer);
880 if let Some(h) = supervisor_handle {
883 let _ = h.join();
884 }
885 manager.shutdown_all();
887 sprintln!("{}", style("Servers stopped.").green());
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894
895 #[test]
902 fn render_banner_matrix() {
903 let b_watch_off_tty = "Backend: http://127.0.0.1:8080\n\
904 Frontend: http://127.0.0.1:5173\n\
905 \n\
906 \x20\x20r rebuild backend + regenerate types\n\
907 \x20\x20q quit (or Ctrl+C)\n\
908 \x20\x20watch disabled (pass --watch to auto-reload on file changes)\n";
909 let b_watch_on_tty = "Backend: http://127.0.0.1:8080\n\
910 Frontend: http://127.0.0.1:5173\n\
911 \n\
912 \x20\x20r rebuild backend + regenerate types\n\
913 \x20\x20q quit (or Ctrl+C)\n\
914 \x20\x20watch enabled (debounce 500ms)\n";
915 let b_watch_off_non_tty = "Backend: http://127.0.0.1:8080\n\
916 Frontend: http://127.0.0.1:5173\n\
917 \n\
918 \x20\x20r unavailable (non-TTY stdin)\n\
919 \x20\x20q quit (or Ctrl+C)\n\
920 \x20\x20watch disabled (pass --watch to auto-reload on file changes)\n";
921 let b_watch_on_non_tty = "Backend: http://127.0.0.1:8080\n\
922 Frontend: http://127.0.0.1:5173\n\
923 \n\
924 \x20\x20r unavailable (non-TTY stdin)\n\
925 \x20\x20q quit (or Ctrl+C)\n\
926 \x20\x20watch enabled (debounce 500ms)\n";
927
928 assert_eq!(
929 render_banner(false, true, false, false, "127.0.0.1", 8080, 5173),
930 b_watch_off_tty,
931 );
932 assert_eq!(
933 render_banner(true, true, false, false, "127.0.0.1", 8080, 5173),
934 b_watch_on_tty,
935 );
936 assert_eq!(
937 render_banner(false, false, false, false, "127.0.0.1", 8080, 5173),
938 b_watch_off_non_tty,
939 );
940 assert_eq!(
941 render_banner(true, false, false, false, "127.0.0.1", 8080, 5173),
942 b_watch_on_non_tty,
943 );
944 }
945
946 #[test]
948 fn classify_key_table() {
949 assert_eq!(
950 classify_key(KeyCode::Char('r'), KeyModifiers::NONE),
951 Some(KbAction::Reload)
952 );
953 assert_eq!(classify_key(KeyCode::Char('R'), KeyModifiers::SHIFT), None);
954 assert_eq!(
955 classify_key(KeyCode::Char('q'), KeyModifiers::NONE),
956 Some(KbAction::Quit)
957 );
958 assert_eq!(
959 classify_key(KeyCode::Char('c'), KeyModifiers::CONTROL),
960 Some(KbAction::Quit)
961 );
962 assert_eq!(classify_key(KeyCode::Char('x'), KeyModifiers::NONE), None);
963 }
964
965 #[test]
967 fn trigger_source_formatting() {
968 assert_eq!(format_trigger_source(ReloadTrigger::Manual), "manual");
969 assert_eq!(
970 format_trigger_source(ReloadTrigger::FileChanged),
971 "file change"
972 );
973 }
974
975 #[test]
977 fn should_spawn_keyboard_gated_on_tty() {
978 assert!(should_spawn_keyboard(true));
979 assert!(!should_spawn_keyboard(false));
980 }
981
982 #[test]
984 fn kill_current_noop_when_none() {
985 let shutdown = Arc::new(AtomicBool::new(false));
986 let mut sup = BackendSupervisor::new(
987 "x".into(),
988 true,
989 PathBuf::from("."),
990 PathBuf::from("."),
991 shutdown,
992 );
993 assert!(sup.current.is_none());
994 sup.kill_current();
995 assert!(sup.current.is_none());
996 }
997
998 #[test]
1000 fn supervisor_coalesces_multiple_triggers() {
1001 let (tx, rx) = channel::<ReloadTrigger>();
1002 tx.send(ReloadTrigger::Manual).unwrap();
1004 tx.send(ReloadTrigger::FileChanged).unwrap();
1005 tx.send(ReloadTrigger::Manual).unwrap();
1006 drop(tx); let first = ReloadTrigger::Manual;
1010 let latest = BackendSupervisor::drain_triggers(&rx, first);
1011 assert!(matches!(latest, ReloadTrigger::Manual));
1012 assert!(
1013 rx.try_recv().is_err(),
1014 "all triggers must have been drained"
1015 );
1016 }
1017
1018 #[cfg(unix)]
1022 #[test]
1023 fn spawn_child_with_prefix_uses_new_process_group() {
1024 let shutdown = Arc::new(AtomicBool::new(false));
1025 let mut child = spawn_child_with_prefix(
1026 "sh",
1027 &["-c", "sleep 30"],
1028 None,
1029 "[t]",
1030 console::Color::Black,
1031 &[],
1032 shutdown,
1033 )
1034 .expect("spawn");
1035 let pid = child.id() as i32;
1036 let pgid = unsafe { libc::getpgid(pid) };
1037 unsafe {
1039 libc::kill(-pid, libc::SIGKILL);
1040 }
1041 let _ = child.wait();
1042 assert_eq!(
1043 pgid, pid,
1044 "child PGID ({pgid}) must equal child PID ({pid}) after setsid"
1045 );
1046 }
1047
1048 #[cfg(unix)]
1052 #[test]
1053 fn terminate_child_group_reaches_grandchild() {
1054 let tmp = tempfile::TempDir::new().expect("tempdir");
1055 let pid_file = tmp.path().join("gc.pid");
1056 let script = format!("sleep 60 & echo $! > {}; wait", pid_file.display());
1057 let shutdown = Arc::new(AtomicBool::new(false));
1058 let mut child = spawn_child_with_prefix(
1059 "sh",
1060 &["-c", &script],
1061 None,
1062 "[t]",
1063 console::Color::Black,
1064 &[],
1065 shutdown,
1066 )
1067 .expect("spawn");
1068
1069 let deadline = std::time::Instant::now() + Duration::from_secs(3);
1071 let grandchild_pid: i32 = loop {
1072 if let Ok(s) = std::fs::read_to_string(&pid_file) {
1073 if let Ok(p) = s.trim().parse::<i32>() {
1074 if p > 0 {
1075 break p;
1076 }
1077 }
1078 }
1079 if std::time::Instant::now() > deadline {
1080 unsafe {
1081 libc::kill(-(child.id() as i32), libc::SIGKILL);
1082 }
1083 let _ = child.wait();
1084 panic!("grandchild PID never recorded");
1085 }
1086 thread::sleep(Duration::from_millis(25));
1087 };
1088 assert_eq!(
1089 unsafe { libc::kill(grandchild_pid, 0) },
1090 0,
1091 "precondition: grandchild must be alive"
1092 );
1093
1094 terminate_child_group(&mut child);
1095
1096 let deadline = std::time::Instant::now() + Duration::from_secs(2);
1099 loop {
1100 if unsafe { libc::kill(grandchild_pid, 0) } != 0 {
1101 return;
1102 }
1103 if std::time::Instant::now() > deadline {
1104 panic!("grandchild {grandchild_pid} still alive after terminate_child_group");
1105 }
1106 thread::sleep(Duration::from_millis(25));
1107 }
1108 }
1109
1110 #[test]
1126 fn debouncer_coalesces_burst() {
1127 let tmp = tempfile::TempDir::new().expect("tempdir");
1128 let src = tmp.path().join("src");
1129 std::fs::create_dir(&src).unwrap();
1130 let src = std::fs::canonicalize(&src).unwrap_or(src);
1133 let (tx, rx) = channel::<ReloadTrigger>();
1134 let debounce = Duration::from_millis(500);
1135 let _debouncer = spawn_file_watcher_at(&src, debounce, tx).expect("debouncer init");
1136
1137 let start = std::time::Instant::now();
1139 for i in 0..10 {
1140 std::fs::write(src.join(format!("f{i}.rs")), "fn main(){}").unwrap();
1141 }
1142 std::fs::write(src.join("unrelated.txt"), "x").unwrap();
1144
1145 let evt = rx
1147 .recv_timeout(debounce * 6)
1148 .expect("at least one trigger within 6× debounce window");
1149 assert!(matches!(evt, ReloadTrigger::FileChanged));
1150 assert!(
1152 start.elapsed() >= debounce - Duration::from_millis(100),
1153 "debounce window too short: {:?}",
1154 start.elapsed()
1155 );
1156 let drain_deadline = std::time::Instant::now() + debounce * 2;
1161 let mut extra = 0usize;
1162 while let Some(remaining) = drain_deadline.checked_duration_since(std::time::Instant::now())
1163 {
1164 match rx.recv_timeout(remaining) {
1165 Ok(_) => extra += 1,
1166 Err(_) => break,
1167 }
1168 }
1169 let total = 1 + extra;
1170 assert!(
1171 total < 11,
1172 "debouncer failed to coalesce: {total} events for 11 writes"
1173 );
1174 }
1175}