fob_cli/commands/
dev.rs

1//! Development server command implementation.
2//!
3//! Orchestrates the entire dev server lifecycle:
4//! - Initial build with validation
5//! - File watching with debouncing
6//! - HTTP server with SSE for hot reload
7//! - Automatic rebuilds on file changes
8//! - Graceful shutdown on Ctrl+C
9
10use crate::cli::DevArgs;
11use crate::dev::{
12    DevBuilder, DevConfig, DevEvent, DevServer, DevServerState, FileChange, FileWatcher,
13    SharedState,
14};
15use crate::error::Result;
16use crate::ui;
17use std::sync::Arc;
18use tokio::signal;
19
20/// Execute the dev command.
21///
22/// # Process Flow
23///
24/// 1. Load and validate dev configuration
25/// 2. Create builder and perform initial build
26/// 3. Start file watcher for auto-rebuild
27/// 4. Start HTTP server with SSE
28/// 5. Main event loop:
29///    - Watch for file changes
30///    - Trigger rebuilds on changes
31///    - Broadcast events to connected clients
32///    - Handle Ctrl+C for graceful shutdown
33///
34/// # Arguments
35///
36/// * `args` - Parsed dev command arguments
37///
38/// # Errors
39///
40/// Returns errors for:
41/// - Invalid configuration
42/// - Build failures
43/// - Server startup failures
44/// - File watcher errors
45pub async fn execute(args: DevArgs) -> Result<()> {
46    ui::info("Starting development server...");
47
48    // Step 1: Load and validate configuration
49    let config = DevConfig::from_args(&args)?;
50    config.validate()?;
51
52    let entry_display = if let Some(ref entry) = args.entry {
53        entry.display().to_string()
54    } else {
55        config
56            .base
57            .entry
58            .first()
59            .cloned()
60            .unwrap_or_else(|| "unknown".to_string())
61    };
62    ui::info(&format!("Entry point: {}", entry_display));
63    ui::info(&format!("Working directory: {}", config.cwd.display()));
64
65    // Step 2: Create shared state
66    // Resolve output directory to absolute path
67    let out_dir = if config.base.out_dir.is_absolute() {
68        config.base.out_dir.clone()
69    } else {
70        config.cwd.join(&config.base.out_dir)
71    };
72    let state = Arc::new(DevServerState::new(out_dir));
73
74    // Step 3: Create builder
75    let builder = DevBuilder::new(config.base.clone(), config.cwd.clone());
76
77    // Step 4: Perform initial build
78    ui::info("Performing initial build...");
79    state.start_build();
80
81    match builder.initial_build().await {
82        Ok((duration_ms, cache, asset_registry)) => {
83            state.complete_build(duration_ms);
84            ui::success(&format!("Initial build completed in {}ms", duration_ms));
85
86            // Update cache and asset registry
87            state.update_cache(cache);
88            if let Some(registry) = asset_registry {
89                state.update_asset_registry(registry);
90            }
91
92            ui::info(&format!(
93                "Cached {} files in memory",
94                state.cache.read().len()
95            ));
96        }
97        Err(e) => {
98            let error_msg = e.to_string();
99            state.fail_build(error_msg.clone());
100            ui::error(&format!("Initial build failed: {}", error_msg));
101            return Err(e);
102        }
103    }
104
105    // Step 5: Start file watcher
106    let (watcher, mut change_rx) = FileWatcher::new(
107        config.cwd.clone(),
108        config.watch_ignore.clone(),
109        config.debounce_ms,
110    )?;
111
112    ui::info(&format!(
113        "Watching for changes in: {}",
114        watcher.root().display()
115    ));
116
117    // Step 6: Start HTTP server in background
118    let server = DevServer::new(config.clone(), state.clone());
119    let mut server_handle = tokio::spawn(async move {
120        if let Err(e) = server.start().await {
121            ui::error(&format!("Server error: {}", e));
122        }
123    });
124
125    // Step 7: Open browser if requested
126    if config.open {
127        open_browser(&config.server_url());
128    }
129
130    // Step 8: Main event loop
131    ui::info("Press Ctrl+C to stop");
132
133    loop {
134        tokio::select! {
135            // File change detected
136            Some(change) = change_rx.recv() => {
137                handle_file_change(change, &builder, &state).await;
138            }
139
140            // Ctrl+C received
141            _ = signal::ctrl_c() => {
142                ui::info("Shutting down development server...");
143                break;
144            }
145
146            // Server task completed (error or shutdown)
147            _ = &mut server_handle => {
148                ui::warning("Server task completed unexpectedly");
149                break;
150            }
151        }
152    }
153
154    ui::success("Development server stopped");
155    Ok(())
156}
157
158/// Handle a file change event.
159///
160/// Triggers rebuild and broadcasts result to connected clients.
161async fn handle_file_change(change: FileChange, builder: &DevBuilder, state: &SharedState) {
162    let path = change.path();
163    ui::info(&format!("File changed: {}", path.display()));
164
165    // Clear cached source code before rebuild to ensure fresh data
166    fob_bundler::diagnostics::clear_source_cache();
167
168    // Start build
169    state.start_build();
170    let _ = state.broadcast(&DevEvent::BuildStarted).await;
171
172    // Perform rebuild
173    match builder.rebuild().await {
174        Ok((duration_ms, cache, asset_registry)) => {
175            // Update state
176            state.complete_build(duration_ms);
177            state.update_cache(cache);
178            if let Some(registry) = asset_registry {
179                state.update_asset_registry(registry);
180            }
181
182            ui::success(&format!("Rebuild completed in {}ms", duration_ms));
183
184            // Broadcast success - this triggers client reload
185            let _ = state
186                .broadcast(&DevEvent::BuildCompleted { duration_ms })
187                .await;
188        }
189        Err(e) => {
190            let error_msg = e.to_string();
191            state.fail_build(error_msg.clone());
192
193            ui::error(&format!("Rebuild failed: {}", error_msg));
194
195            // Broadcast failure - error overlay will be shown
196            let _ = state
197                .broadcast(&DevEvent::BuildFailed { error: error_msg })
198                .await;
199        }
200    }
201}
202
203/// Open the server URL in the default browser.
204///
205/// Uses platform-specific commands:
206/// - macOS: `open`
207/// - Windows: `start`
208/// - Linux: `xdg-open`
209fn open_browser(url: &str) {
210    use std::process::Command;
211
212    let result = if cfg!(target_os = "macos") {
213        Command::new("open").arg(url).spawn()
214    } else if cfg!(target_os = "windows") {
215        Command::new("cmd").args(["/C", "start", url]).spawn()
216    } else {
217        Command::new("xdg-open").arg(url).spawn()
218    };
219
220    match result {
221        Ok(_) => ui::info(&format!("Opened browser at {}", url)),
222        Err(e) => ui::warning(&format!("Failed to open browser: {}", e)),
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    #[test]
229    fn test_open_browser_url_format() {
230        // Just verify the function doesn't panic with various URLs
231        // Actual browser opening depends on platform and is non-deterministic
232        let urls = vec![
233            "http://localhost:3000",
234            "http://127.0.0.1:3000",
235            "https://localhost:3000",
236        ];
237
238        for url in urls {
239            // This should not panic
240            let _ = std::panic::catch_unwind(|| {
241                // Don't actually open browser in tests
242                // Just validate URL format
243                assert!(url.starts_with("http"));
244            });
245        }
246    }
247}