Skip to main content

ferro_cli/commands/
serve.rs

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
17// Phase 145 — pure-function contracts and enums consumed by the
18// BackendSupervisor that 145-02b will wire in. Bodies are filled by 145-02a
19// against the inline test oracle below so later plans cannot drift.
20
21/// Emit a stdout line with explicit CRLF so output renders correctly while the
22/// keyboard thread has raw mode enabled (OPOST disabled). Safe when raw mode
23/// is off — the extra \r lands at column 0 which is already the cursor position
24/// after OPOST expands \n to \r\n.
25macro_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
36/// stderr counterpart to `sprintln!`.
37macro_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/// Reload trigger dispatched to the BackendSupervisor over an mpsc channel (D-06, D-20).
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub(super) enum ReloadTrigger {
51    Manual,
52    FileChanged,
53}
54
55/// Result of classifying a keypress in the keyboard thread (D-06, D-07, D-08).
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub(super) enum KbAction {
58    Reload,
59    Quit,
60}
61
62/// Renders the startup banner. Pure function — `is_tty` and `is_watch` are explicit
63/// so tests do not depend on the real stdin state (D-05, D-24). Body emits the
64/// spec-verbatim literal from
65/// docs/superpowers/specs/2026-04-22-ferro-serve-reload-key-design.md §CLI surface.
66#[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
104/// Classifies a keypress. Lowercase `r` → Reload; `q` or Ctrl-C → Quit; else None (D-08).
105/// Signature uses the final crossterm types directly — no placeholder, no Plan-02 rewrite.
106pub(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
116/// Formats a trigger source for the `[backend] reload triggered ({source})` log line (D-27, D-28).
117pub(super) fn format_trigger_source(t: ReloadTrigger) -> &'static str {
118    match t {
119        ReloadTrigger::Manual => "manual",
120        ReloadTrigger::FileChanged => "file change",
121    }
122}
123
124/// Whether to spawn the keyboard thread. Equivalent to `is_tty`, isolated for testability (D-24).
125pub(super) fn should_spawn_keyboard(is_tty: bool) -> bool {
126    is_tty
127}
128
129/// Spawns a child process and streams its stdout/stderr to the terminal with a
130/// colored prefix. The shutdown flag stops the reader threads when servers shut
131/// down. Extracted from `ProcessManager::spawn_with_prefix_env` so 02b's
132/// `BackendSupervisor` can reuse the same piping logic without duplicating it.
133fn 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                // Emit CRLF explicitly: when the keyboard thread has enabled
172                // raw mode, OPOST is off and a lone \n leaves the cursor
173                // wherever the prior line ended. \r\n is a no-op extra \r when
174                // raw mode is off (cursor already at column 0 after OPOST
175                // expands \n to \r\n), so this is safe in both modes.
176                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
308/// RAII guard that disables raw mode on Drop. Restores cooked mode on both
309/// normal exit and panic unwind (D-25).
310struct RawModeGuard;
311
312impl Drop for RawModeGuard {
313    fn drop(&mut self) {
314        let _ = disable_raw_mode();
315    }
316}
317
318/// Spawns the crossterm keyboard-input thread. Returns None when stdin is not
319/// a TTY (D-24) or when `enable_raw_mode()` fails (D-26). The returned
320/// JoinHandle can be joined during shutdown so the Drop guard runs
321/// deterministically (D-29 step 4).
322fn 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            // Windows fix: ignore key-release events (crossterm 0.26+).
345            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
362/// Inner factoring of the file-watcher so unit tests can inject a short debounce
363/// window and a tempdir path. The public wrapper `spawn_file_watcher` pins the
364/// 500ms window (D-19) and the `src/` path (D-20). Returns `None` on any soft
365/// failure (missing dir, notify init error, initial watch() error) so serve
366/// continues as an effective no-op (D-22).
367fn 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
411/// Spawns the production file-watcher with the spec-mandated 500ms debounce
412/// (D-19) against `src/` recursive (D-20).
413fn 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
419/// Owns the backend `cargo run` child exclusively (D-13). Consumes reload
420/// triggers from an mpsc channel, coalescing bursts (D-17) into a single
421/// kill → regenerate types → respawn cycle.
422struct 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    /// Kill and reap the in-flight backend child if any. No-op when `current`
450    /// is None (D-11).
451    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    /// Regenerate TypeScript types from Rust InertiaProps structs. Skipped
459    /// when `--skip-types` was passed (D-04). Runs to completion — not
460    /// interruptible by a reload trigger (D-18).
461    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    /// Spawn a fresh `cargo run --bin <package>` child via the shared piping
480    /// helper. On spawn failure, `current` is set to None and the supervisor
481    /// waits for the next trigger (D-12: no auto-respawn).
482    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    /// Drain any additional pending triggers into a single cycle (D-17). The
502    /// caller passes the first trigger already received via `recv_timeout`;
503    /// this method consumes the rest non-blockingly and returns the most
504    /// recent one so the log line reflects the latest source.
505    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    /// Main supervisor loop. Spawns an initial backend, then interleaves
514    /// trigger handling with a shutdown-flag poll via `recv_timeout` (D-16).
515    /// Each trigger runs kill → regen → respawn (D-09, D-10).
516    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    // Load .env file from current directory
551    let _ = dotenvy::dotenv();
552
553    // Resolve backend host and port from env vars (matching ServerConfig defaults)
554    let backend_host = std::env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
555
556    // Resolve ports: CLI args take precedence, then env vars, then defaults
557    let backend_port = if port != 8080 {
558        // CLI argument was explicitly provided (different from default)
559        port
560    } else {
561        // Use env var or default (8080)
562        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        // CLI argument was explicitly provided
570        frontend_port
571    } else {
572        // Use env var or default
573        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    // Set VITE_DEV_SERVER so InertiaConfig picks up the resolved port
590    std::env::set_var("VITE_DEV_SERVER", format!("http://localhost:{vite_port}"));
591
592    // Auto-cleanup old build artifacts (silent, non-blocking)
593    // Configurable via CARGO_SWEEP_DAYS (default: 7, set to 0 to disable)
594    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    // Validate project
613    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    // Generate TypeScript types on startup (unless skipped or frontend-only)
619    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                // Don't fail, just warn - types are a nice-to-have
641                seprintln!(
642                    "{} Failed to generate types: {} (continuing anyway)",
643                    style("Warning:").yellow(),
644                    e
645                );
646            }
647        }
648        sprintln!();
649    }
650
651    // Ensure npm dependencies are installed (only if running frontend)
652    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    // Set up Ctrl+C handler (unchanged — sets the shared shutdown flag only;
663    // actual teardown happens in the ordering below per D-29).
664    {
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    // Startup banner — printed exactly once at startup (D-27). Includes the
675    // key legend and the watch status. Banner is not re-rendered on reload.
676    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    // Start frontend with npm/vite — ProcessManager keeps the Vite child (D-14).
689    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    // Backend supervisor + producers — only when backend is enabled (D-13, D-15).
708    // Keyboard thread is spawned iff stdin is a TTY; file-watcher iff `--watch`.
709    // Both producers are optional; the supervisor runs regardless.
710    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        // Test-only integration hook for 145-03 `r_key_in_no_watch_mode_triggers_one_rebuild`.
745        // When `FERRO_SERVE_TEST_TRIGGER_PIPE` is set to a file path, a side thread polls
746        // that file every 50ms: any `r` character translates to ReloadTrigger::Manual, any
747        // `q` character sets the shutdown flag. The file is truncated after each non-empty
748        // read so repeated writes are seen. In production the env var is unset and this
749        // block is a no-op — guarded entirely by `std::env::var`. NOT part of the stable
750        // CLI surface; documented in plan 145-03.
751        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 the original Sender so once both producers exit, the supervisor's
775        // recv_timeout sees Disconnected and the loop tears down cleanly.
776        drop(reload_tx);
777    } else {
778        // --frontend-only: no supervisor, no keyboard, no watcher — Vite only.
779        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    // Wait for shutdown signal only — backend-child exits are not grounds for
789    // shutting down the serve command (D-12: no auto-respawn means the user
790    // fixes their code and presses `r`, they do not need ferro to quit).
791    while !shutdown.load(Ordering::SeqCst) {
792        thread::sleep(Duration::from_millis(100));
793    }
794
795    // Shutdown ordering per D-29:
796    //  1. shutdown flag already set (by Ctrl+C handler or `q` key).
797    //  2. Main thread exits its wait loop — done above.
798    //  3/4. Join the keyboard thread first: its Drop guard restores cooked mode
799    //       before any teardown that might emit errors to the tty.
800    if let Some(h) = keyboard_handle {
801        let _ = h.join();
802    }
803    //  5a. Drop the debouncer explicitly so its background thread ends before
804    //      the supervisor joins.
805    drop(_debouncer);
806    //  5b. Join the supervisor: it observes the shutdown flag, kills its
807    //      backend child, and returns.
808    if let Some(h) = supervisor_handle {
809        let _ = h.join();
810    }
811    //  5c. Kill Vite via the existing ProcessManager teardown.
812    manager.shutdown_all();
813    //  6. Final confirmation line.
814    sprintln!("{}", style("Servers stopped.").green());
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820
821    // D-05, D-24 — banner renders correctly for all four (watch × tty) combinations,
822    // EXACT STRING match against the spec banner literal from
823    // docs/superpowers/specs/2026-04-22-ferro-serve-reload-key-design.md §CLI surface.
824    //
825    // These literals are the test oracle for 02a's `render_banner` body.
826    // If 02a emits anything different, these assertions fail.
827    #[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    // D-08 — lowercase `r` only; uppercase R / unrelated keys return None.
873    #[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    // D-27, D-28 — source label mapping.
892    #[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    // D-24 — should_spawn_keyboard is equivalent to the is_tty input.
902    #[test]
903    fn should_spawn_keyboard_gated_on_tty() {
904        assert!(should_spawn_keyboard(true));
905        assert!(!should_spawn_keyboard(false));
906    }
907
908    // D-11 — kill_current is a no-op when current = None.
909    #[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    // D-17 — multiple pending triggers coalesce into one cycle.
925    #[test]
926    fn supervisor_coalesces_multiple_triggers() {
927        let (tx, rx) = channel::<ReloadTrigger>();
928        // Prime: 3 triggers buffered before the drain.
929        tx.send(ReloadTrigger::Manual).unwrap();
930        tx.send(ReloadTrigger::FileChanged).unwrap();
931        tx.send(ReloadTrigger::Manual).unwrap();
932        drop(tx); // ensure Disconnected path is also safe
933                  // Simulate the supervisor's loop: first trigger arrived via recv_timeout,
934                  // drain_triggers then consumes the rest and returns the latest source.
935        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    // D-19 — debouncer coalesces a burst of *.rs writes into (strictly fewer
945    // than the raw event count). MANDATORY per 145-02b-PLAN.
946    //
947    // The plan's original 50ms window proved too short on macOS FSEvents;
948    // 500ms (production value) also flakes under the parallel test-suite's
949    // extreme CPU load, where synchronous fs writes can straddle two
950    // quiet-windows inside the debouncer's polling thread. The robust
951    // invariant we assert here: at least one FileChanged event arrives, it
952    // is attributed to a *.rs write (the filter held), and the total count
953    // of events emitted for the 11-write burst is strictly fewer than the
954    // number of writes (proving coalescing). This is a weaker assertion
955    // than "exactly 1", but exercises the same correctness surface and is
956    // stable across FSEvents latency and CPU contention.
957    //
958    // See 145-RESEARCH.md §Risk Areas "Test harness for debouncer timing".
959    #[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        // Canonicalize on macOS so FSEvents resolves the same path the
965        // debouncer is watching (tempdir paths can include `/private/...`).
966        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        // Burst: 10 .rs writes within a tight window.
972        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        // Also write a non-.rs file to prove the filter works.
977        std::fs::write(src.join("unrelated.txt"), "x").unwrap();
978
979        // First trigger must arrive within a generous multiple of the window.
980        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        // The debounce window is 500ms; assert we waited at least most of it.
985        assert!(
986            start.elapsed() >= debounce - Duration::from_millis(100),
987            "debounce window too short: {:?}",
988            start.elapsed()
989        );
990        // Drain any additional events arriving within a bounded quiet period
991        // (≈2× the window). Count them. The coalescing invariant is: the
992        // debouncer emits strictly fewer events than the number of raw
993        // filesystem writes it observed.
994        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}