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 server on http://{backend_host}:{backend_port}");
80 }
81 if !backend_only {
82 let _ = writeln!(s, "Frontend server on 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
129fn spawn_child_with_prefix(
134 command: &str,
135 args: &[&str],
136 cwd: Option<&Path>,
137 prefix: &str,
138 color: console::Color,
139 env_vars: &[(&str, &str)],
140 shutdown: Arc<AtomicBool>,
141) -> Result<Child, String> {
142 let mut cmd = Command::new(command);
143 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
144
145 for (key, value) in env_vars {
146 cmd.env(key, value);
147 }
148
149 if let Some(dir) = cwd {
150 cmd.current_dir(dir);
151 }
152
153 let mut child = cmd
154 .spawn()
155 .map_err(|e| format!("Failed to spawn {command}: {e}"))?;
156
157 let stdout = child.stdout.take().expect("stdout piped");
158 let stderr = child.stderr.take().expect("stderr piped");
159 let prefix_out = prefix.to_string();
160 let prefix_err = prefix.to_string();
161 let sd_out = shutdown.clone();
162 let sd_err = shutdown;
163
164 thread::spawn(move || {
165 let reader = BufReader::new(stdout);
166 for line in reader.lines() {
167 if sd_out.load(Ordering::SeqCst) {
168 break;
169 }
170 if let Ok(line) = line {
171 print!("{} {}\r\n", style(&prefix_out).fg(color).bold(), line);
177 let _ = io::stdout().flush();
178 }
179 }
180 });
181
182 thread::spawn(move || {
183 let reader = BufReader::new(stderr);
184 for line in reader.lines() {
185 if sd_err.load(Ordering::SeqCst) {
186 break;
187 }
188 if let Ok(line) = line {
189 eprint!("{} {}\r\n", style(&prefix_err).fg(color).bold(), line);
190 let _ = io::stderr().flush();
191 }
192 }
193 });
194
195 Ok(child)
196}
197
198struct ProcessManager {
199 children: Vec<Child>,
200 shutdown: Arc<AtomicBool>,
201}
202
203impl ProcessManager {
204 fn new() -> Self {
205 Self {
206 children: Vec::new(),
207 shutdown: Arc::new(AtomicBool::new(false)),
208 }
209 }
210
211 fn spawn_with_prefix_env(
212 &mut self,
213 command: &str,
214 args: &[&str],
215 cwd: Option<&Path>,
216 prefix: &str,
217 color: console::Color,
218 env_vars: &[(&str, &str)],
219 ) -> Result<(), String> {
220 let child = spawn_child_with_prefix(
221 command,
222 args,
223 cwd,
224 prefix,
225 color,
226 env_vars,
227 self.shutdown.clone(),
228 )?;
229 self.children.push(child);
230 Ok(())
231 }
232
233 fn shutdown_all(&mut self) {
234 self.shutdown.store(true, Ordering::SeqCst);
235 for child in &mut self.children {
236 let _ = child.kill();
237 let _ = child.wait();
238 }
239 }
240}
241
242fn get_package_name() -> Result<String, String> {
243 let cargo_toml = Path::new("Cargo.toml");
244 let content = std::fs::read_to_string(cargo_toml)
245 .map_err(|e| format!("Failed to read Cargo.toml: {e}"))?;
246
247 let parsed: toml::Value = content
248 .parse()
249 .map_err(|e| format!("Failed to parse Cargo.toml: {e}"))?;
250
251 parsed
252 .get("package")
253 .and_then(|p| p.get("name"))
254 .and_then(|n| n.as_str())
255 .map(|s| s.to_string())
256 .ok_or_else(|| "Could not find package name in Cargo.toml".to_string())
257}
258
259fn validate_ferro_project(backend_only: bool, frontend_only: bool) -> Result<(), String> {
260 let cargo_toml = Path::new("Cargo.toml");
261 let frontend_dir = Path::new("frontend");
262
263 if !frontend_only && !cargo_toml.exists() {
264 return Err("No Cargo.toml found. Are you in a Ferro project directory?".into());
265 }
266
267 if !backend_only && !frontend_dir.exists() {
268 return Err("No frontend directory found. Are you in a Ferro project directory?".into());
269 }
270
271 Ok(())
272}
273
274fn ensure_npm_dependencies() -> Result<(), String> {
275 let frontend_path = Path::new("frontend");
276 let node_modules = frontend_path.join("node_modules");
277
278 if !node_modules.exists() {
279 sprintln!("{}", style("Installing frontend dependencies...").yellow());
280 let npm_install = Command::new("npm")
281 .args(["install"])
282 .current_dir(frontend_path)
283 .status()
284 .map_err(|e| format!("Failed to run npm install: {e}"))?;
285
286 if !npm_install.success() {
287 return Err("Failed to install npm dependencies".into());
288 }
289 sprintln!(
290 "{}",
291 style("Frontend dependencies installed successfully.").green()
292 );
293 }
294
295 Ok(())
296}
297
298fn find_available_port(start: u16, max_attempts: u16) -> u16 {
299 for offset in 0..max_attempts {
300 let port = start + offset;
301 if TcpListener::bind(("127.0.0.1", port)).is_ok() {
302 return port;
303 }
304 }
305 start
306}
307
308struct RawModeGuard;
311
312impl Drop for RawModeGuard {
313 fn drop(&mut self) {
314 let _ = disable_raw_mode();
315 }
316}
317
318fn spawn_keyboard_thread(
323 tx: Sender<ReloadTrigger>,
324 shutdown: Arc<AtomicBool>,
325) -> Option<JoinHandle<()>> {
326 let is_tty = std::io::stdin().is_terminal();
327 if !should_spawn_keyboard(is_tty) {
328 return None;
329 }
330 if let Err(e) = enable_raw_mode() {
331 seprintln!("{} raw mode unavailable: {e}", style("Warning:").yellow());
332 return None;
333 }
334 Some(thread::spawn(move || {
335 let _guard = RawModeGuard;
336 while !shutdown.load(Ordering::SeqCst) {
337 match event::poll(Duration::from_millis(100)) {
338 Ok(true) => {}
339 _ => continue,
340 }
341 let Ok(Event::Key(k)) = event::read() else {
342 continue;
343 };
344 if k.kind != KeyEventKind::Press {
346 continue;
347 }
348 match classify_key(k.code, k.modifiers) {
349 Some(KbAction::Reload) => {
350 let _ = tx.send(ReloadTrigger::Manual);
351 }
352 Some(KbAction::Quit) => {
353 shutdown.store(true, Ordering::SeqCst);
354 break;
355 }
356 None => {}
357 }
358 }
359 }))
360}
361
362fn spawn_file_watcher_at(
368 src: &Path,
369 debounce: Duration,
370 tx: Sender<ReloadTrigger>,
371) -> Option<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>> {
372 if !src.is_dir() {
373 seprintln!(
374 "{} {} missing, --watch disabled",
375 style("Warning:").yellow(),
376 src.display()
377 );
378 return None;
379 }
380 let mut debouncer = match new_debouncer(
381 debounce,
382 move |res: notify_debouncer_mini::DebounceEventResult| {
383 let Ok(events) = res else {
384 return;
385 };
386 let any_rs = events
387 .iter()
388 .any(|e: &DebouncedEvent| e.path.extension().map(|x| x == "rs").unwrap_or(false));
389 if any_rs {
390 let _ = tx.send(ReloadTrigger::FileChanged);
391 }
392 },
393 ) {
394 Ok(d) => d,
395 Err(e) => {
396 seprintln!("{} notify init failed: {e}", style("Warning:").yellow());
397 return None;
398 }
399 };
400 if let Err(e) = debouncer.watcher().watch(src, RecursiveMode::Recursive) {
401 seprintln!(
402 "{} watch({}) failed: {e}",
403 style("Warning:").yellow(),
404 src.display()
405 );
406 return None;
407 }
408 Some(debouncer)
409}
410
411fn spawn_file_watcher(
414 tx: Sender<ReloadTrigger>,
415) -> Option<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>> {
416 spawn_file_watcher_at(Path::new("src"), Duration::from_millis(500), tx)
417}
418
419struct BackendSupervisor {
423 package_name: String,
424 skip_types: bool,
425 project_path: PathBuf,
426 types_output_path: PathBuf,
427 current: Option<Child>,
428 shutdown: Arc<AtomicBool>,
429}
430
431impl BackendSupervisor {
432 fn new(
433 package_name: String,
434 skip_types: bool,
435 project_path: PathBuf,
436 types_output_path: PathBuf,
437 shutdown: Arc<AtomicBool>,
438 ) -> Self {
439 Self {
440 package_name,
441 skip_types,
442 project_path,
443 types_output_path,
444 current: None,
445 shutdown,
446 }
447 }
448
449 fn kill_current(&mut self) {
452 if let Some(mut child) = self.current.take() {
453 let _ = child.kill();
454 let _ = child.wait();
455 }
456 }
457
458 fn regenerate_types(&self) {
462 if self.skip_types {
463 return;
464 }
465 match super::generate_types::generate_types_to_file(
466 &self.project_path,
467 &self.types_output_path,
468 ) {
469 Ok(count) if count > 0 => {
470 sprintln!("{} Regenerated {} type(s)", style("[types]").blue(), count);
471 }
472 Ok(_) => {}
473 Err(e) => {
474 seprintln!("{} Failed to regenerate: {}", style("[types]").yellow(), e);
475 }
476 }
477 }
478
479 fn spawn_backend(&mut self) {
483 let args = ["run", "--bin", self.package_name.as_str()];
484 match spawn_child_with_prefix(
485 "cargo",
486 &args,
487 None,
488 "[backend]",
489 console::Color::Magenta,
490 &[],
491 self.shutdown.clone(),
492 ) {
493 Ok(child) => self.current = Some(child),
494 Err(e) => {
495 seprintln!("{} {}", style("Error:").red().bold(), e);
496 self.current = None;
497 }
498 }
499 }
500
501 fn drain_triggers(rx: &Receiver<ReloadTrigger>, initial: ReloadTrigger) -> ReloadTrigger {
506 let mut latest = initial;
507 while let Ok(next) = rx.try_recv() {
508 latest = next;
509 }
510 latest
511 }
512
513 fn run_loop(&mut self, rx: Receiver<ReloadTrigger>) {
517 self.spawn_backend();
518 loop {
519 if self.shutdown.load(Ordering::SeqCst) {
520 self.kill_current();
521 break;
522 }
523 match rx.recv_timeout(Duration::from_millis(100)) {
524 Ok(initial) => {
525 let src = Self::drain_triggers(&rx, initial);
526 sprintln!(
527 "{} reload triggered ({})",
528 style("[backend]").magenta().bold(),
529 format_trigger_source(src)
530 );
531 self.kill_current();
532 self.regenerate_types();
533 self.spawn_backend();
534 }
535 Err(RecvTimeoutError::Timeout) => continue,
536 Err(RecvTimeoutError::Disconnected) => break,
537 }
538 }
539 }
540}
541
542pub fn run(
543 port: u16,
544 frontend_port: u16,
545 backend_only: bool,
546 frontend_only: bool,
547 skip_types: bool,
548 watch: bool,
549) {
550 let _ = dotenvy::dotenv();
552
553 let backend_host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
555
556 let backend_port = if port != 8080 {
558 port
560 } else {
561 std::env::var("SERVER_PORT")
563 .ok()
564 .and_then(|v| v.parse().ok())
565 .unwrap_or(8080)
566 };
567
568 let requested_vite_port = if frontend_port != 5173 {
569 frontend_port
571 } else {
572 std::env::var("VITE_PORT")
574 .ok()
575 .and_then(|v| v.parse().ok())
576 .unwrap_or(frontend_port)
577 };
578
579 let vite_port = find_available_port(requested_vite_port, 10);
580 if vite_port != requested_vite_port {
581 sprintln!(
582 "{} Port {} in use, using {} instead",
583 style("[frontend]").cyan().bold(),
584 requested_vite_port,
585 vite_port
586 );
587 }
588
589 std::env::set_var("VITE_DEV_SERVER", format!("http://localhost:{vite_port}"));
591
592 let sweep_days: u32 = std::env::var("CARGO_SWEEP_DAYS")
595 .ok()
596 .and_then(|v| v.parse().ok())
597 .unwrap_or(7);
598
599 if sweep_days > 0 {
600 if let Some(cleaned) = clean::run_silent(sweep_days) {
601 sprintln!("{} {}", style("♻").cyan(), cleaned);
602 }
603 }
604
605 sprintln!();
606 sprintln!(
607 "{}",
608 style("Starting Ferro development servers...").cyan().bold()
609 );
610 sprintln!();
611
612 if let Err(e) = validate_ferro_project(backend_only, frontend_only) {
614 seprintln!("{} {}", style("Error:").red().bold(), e);
615 std::process::exit(1);
616 }
617
618 if !skip_types && !frontend_only {
620 let project_path = Path::new(".");
621 let output_path = project_path.join("frontend/src/types/inertia-props.ts");
622
623 sprintln!("{}", style("Generating TypeScript types...").cyan());
624 match super::generate_types::generate_types_to_file(project_path, &output_path) {
625 Ok(0) => {
626 sprintln!(
627 "{}",
628 style("No InertiaProps structs found (skipping type generation)").dim()
629 );
630 }
631 Ok(count) => {
632 sprintln!(
633 "{} Generated {} type(s) to {}",
634 style("✓").green(),
635 count,
636 output_path.display()
637 );
638 }
639 Err(e) => {
640 seprintln!(
642 "{} Failed to generate types: {} (continuing anyway)",
643 style("Warning:").yellow(),
644 e
645 );
646 }
647 }
648 sprintln!();
649 }
650
651 if !backend_only {
653 if let Err(e) = ensure_npm_dependencies() {
654 seprintln!("{} {}", style("Error:").red().bold(), e);
655 std::process::exit(1);
656 }
657 }
658
659 let mut manager = ProcessManager::new();
660 let shutdown = manager.shutdown.clone();
661
662 {
665 let shutdown = shutdown.clone();
666 ctrlc::set_handler(move || {
667 sprintln!();
668 sprintln!("{}", style("Shutting down servers...").yellow());
669 shutdown.store(true, Ordering::SeqCst);
670 })
671 .expect("Error setting Ctrl-C handler");
672 }
673
674 let is_tty = std::io::stdin().is_terminal();
677 let banner = render_banner(
678 watch,
679 is_tty,
680 backend_only,
681 frontend_only,
682 &backend_host,
683 backend_port,
684 vite_port,
685 );
686 print!("{banner}");
687
688 if !backend_only {
690 let frontend_path = Path::new("frontend");
691 let vite_port_str = vite_port.to_string();
692
693 if let Err(e) = manager.spawn_with_prefix_env(
694 "npm",
695 &["run", "dev", "--", "--port", &vite_port_str, "--strictPort"],
696 Some(frontend_path),
697 "[frontend]",
698 console::Color::Cyan,
699 &[],
700 ) {
701 seprintln!("{} {}", style("Error:").red().bold(), e);
702 manager.shutdown_all();
703 std::process::exit(1);
704 }
705 }
706
707 let supervisor_handle: Option<JoinHandle<()>>;
711 let keyboard_handle: Option<JoinHandle<()>>;
712 let _debouncer: Option<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>>;
713
714 if !frontend_only {
715 let package_name = match get_package_name() {
716 Ok(name) => name,
717 Err(e) => {
718 seprintln!("{} {}", style("Error:").red().bold(), e);
719 manager.shutdown_all();
720 std::process::exit(1);
721 }
722 };
723
724 let project_path = Path::new(".").to_path_buf();
725 let types_output_path = project_path.join("frontend/src/types/inertia-props.ts");
726
727 let (reload_tx, reload_rx) = channel::<ReloadTrigger>();
728 keyboard_handle = spawn_keyboard_thread(reload_tx.clone(), shutdown.clone());
729 _debouncer = if watch {
730 spawn_file_watcher(reload_tx.clone())
731 } else {
732 None
733 };
734
735 let mut supervisor = BackendSupervisor::new(
736 package_name,
737 skip_types,
738 project_path,
739 types_output_path,
740 shutdown.clone(),
741 );
742 supervisor_handle = Some(thread::spawn(move || supervisor.run_loop(reload_rx)));
743
744 if let Ok(pipe_path) = std::env::var("FERRO_SERVE_TEST_TRIGGER_PIPE") {
752 let tx = reload_tx.clone();
753 let sd = shutdown.clone();
754 thread::spawn(move || loop {
755 if sd.load(Ordering::SeqCst) {
756 break;
757 }
758 if let Ok(content) = std::fs::read_to_string(&pipe_path) {
759 if !content.is_empty() {
760 if content.contains('r') {
761 let _ = tx.send(ReloadTrigger::Manual);
762 }
763 if content.contains('q') {
764 sd.store(true, Ordering::SeqCst);
765 break;
766 }
767 let _ = std::fs::write(&pipe_path, "");
768 }
769 }
770 thread::sleep(Duration::from_millis(50));
771 });
772 }
773
774 drop(reload_tx);
777 } else {
778 supervisor_handle = None;
780 keyboard_handle = None;
781 _debouncer = None;
782 }
783
784 sprintln!();
785 sprintln!("{}", style("Press Ctrl+C to stop all servers").dim());
786 sprintln!();
787
788 while !shutdown.load(Ordering::SeqCst) {
792 thread::sleep(Duration::from_millis(100));
793 }
794
795 if let Some(h) = keyboard_handle {
801 let _ = h.join();
802 }
803 drop(_debouncer);
806 if let Some(h) = supervisor_handle {
809 let _ = h.join();
810 }
811 manager.shutdown_all();
813 sprintln!("{}", style("Servers stopped.").green());
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 #[test]
828 fn render_banner_matrix() {
829 let b_watch_off_tty = "Backend server on http://127.0.0.1:8080\n\
830 Frontend server on http://127.0.0.1:5173\n\
831 \n\
832 \x20\x20r rebuild backend + regenerate types\n\
833 \x20\x20q quit (or Ctrl+C)\n\
834 \x20\x20watch disabled (pass --watch to auto-reload on file changes)\n";
835 let b_watch_on_tty = "Backend server on http://127.0.0.1:8080\n\
836 Frontend server on http://127.0.0.1:5173\n\
837 \n\
838 \x20\x20r rebuild backend + regenerate types\n\
839 \x20\x20q quit (or Ctrl+C)\n\
840 \x20\x20watch enabled (debounce 500ms)\n";
841 let b_watch_off_non_tty = "Backend server on http://127.0.0.1:8080\n\
842 Frontend server on http://127.0.0.1:5173\n\
843 \n\
844 \x20\x20r unavailable (non-TTY stdin)\n\
845 \x20\x20q quit (or Ctrl+C)\n\
846 \x20\x20watch disabled (pass --watch to auto-reload on file changes)\n";
847 let b_watch_on_non_tty = "Backend server on http://127.0.0.1:8080\n\
848 Frontend server on http://127.0.0.1:5173\n\
849 \n\
850 \x20\x20r unavailable (non-TTY stdin)\n\
851 \x20\x20q quit (or Ctrl+C)\n\
852 \x20\x20watch enabled (debounce 500ms)\n";
853
854 assert_eq!(
855 render_banner(false, true, false, false, "127.0.0.1", 8080, 5173),
856 b_watch_off_tty,
857 );
858 assert_eq!(
859 render_banner(true, true, false, false, "127.0.0.1", 8080, 5173),
860 b_watch_on_tty,
861 );
862 assert_eq!(
863 render_banner(false, false, false, false, "127.0.0.1", 8080, 5173),
864 b_watch_off_non_tty,
865 );
866 assert_eq!(
867 render_banner(true, false, false, false, "127.0.0.1", 8080, 5173),
868 b_watch_on_non_tty,
869 );
870 }
871
872 #[test]
874 fn classify_key_table() {
875 assert_eq!(
876 classify_key(KeyCode::Char('r'), KeyModifiers::NONE),
877 Some(KbAction::Reload)
878 );
879 assert_eq!(classify_key(KeyCode::Char('R'), KeyModifiers::SHIFT), None);
880 assert_eq!(
881 classify_key(KeyCode::Char('q'), KeyModifiers::NONE),
882 Some(KbAction::Quit)
883 );
884 assert_eq!(
885 classify_key(KeyCode::Char('c'), KeyModifiers::CONTROL),
886 Some(KbAction::Quit)
887 );
888 assert_eq!(classify_key(KeyCode::Char('x'), KeyModifiers::NONE), None);
889 }
890
891 #[test]
893 fn trigger_source_formatting() {
894 assert_eq!(format_trigger_source(ReloadTrigger::Manual), "manual");
895 assert_eq!(
896 format_trigger_source(ReloadTrigger::FileChanged),
897 "file change"
898 );
899 }
900
901 #[test]
903 fn should_spawn_keyboard_gated_on_tty() {
904 assert!(should_spawn_keyboard(true));
905 assert!(!should_spawn_keyboard(false));
906 }
907
908 #[test]
910 fn kill_current_noop_when_none() {
911 let shutdown = Arc::new(AtomicBool::new(false));
912 let mut sup = BackendSupervisor::new(
913 "x".into(),
914 true,
915 PathBuf::from("."),
916 PathBuf::from("."),
917 shutdown,
918 );
919 assert!(sup.current.is_none());
920 sup.kill_current();
921 assert!(sup.current.is_none());
922 }
923
924 #[test]
926 fn supervisor_coalesces_multiple_triggers() {
927 let (tx, rx) = channel::<ReloadTrigger>();
928 tx.send(ReloadTrigger::Manual).unwrap();
930 tx.send(ReloadTrigger::FileChanged).unwrap();
931 tx.send(ReloadTrigger::Manual).unwrap();
932 drop(tx); let first = ReloadTrigger::Manual;
936 let latest = BackendSupervisor::drain_triggers(&rx, first);
937 assert!(matches!(latest, ReloadTrigger::Manual));
938 assert!(
939 rx.try_recv().is_err(),
940 "all triggers must have been drained"
941 );
942 }
943
944 #[test]
960 fn debouncer_coalesces_burst() {
961 let tmp = tempfile::TempDir::new().expect("tempdir");
962 let src = tmp.path().join("src");
963 std::fs::create_dir(&src).unwrap();
964 let src = std::fs::canonicalize(&src).unwrap_or(src);
967 let (tx, rx) = channel::<ReloadTrigger>();
968 let debounce = Duration::from_millis(500);
969 let _debouncer = spawn_file_watcher_at(&src, debounce, tx).expect("debouncer init");
970
971 let start = std::time::Instant::now();
973 for i in 0..10 {
974 std::fs::write(src.join(format!("f{i}.rs")), "fn main(){}").unwrap();
975 }
976 std::fs::write(src.join("unrelated.txt"), "x").unwrap();
978
979 let evt = rx
981 .recv_timeout(debounce * 6)
982 .expect("at least one trigger within 6× debounce window");
983 assert!(matches!(evt, ReloadTrigger::FileChanged));
984 assert!(
986 start.elapsed() >= debounce - Duration::from_millis(100),
987 "debounce window too short: {:?}",
988 start.elapsed()
989 );
990 let drain_deadline = std::time::Instant::now() + debounce * 2;
995 let mut extra = 0usize;
996 while let Some(remaining) = drain_deadline.checked_duration_since(std::time::Instant::now())
997 {
998 match rx.recv_timeout(remaining) {
999 Ok(_) => extra += 1,
1000 Err(_) => break,
1001 }
1002 }
1003 let total = 1 + extra;
1004 assert!(
1005 total < 11,
1006 "debouncer failed to coalesce: {total} events for 11 writes"
1007 );
1008 }
1009}