Skip to main content

probador/
dev_server.rs

1//! WASM Development Server
2//!
3//! Real HTTP server implementation for serving WASM applications with hot reload.
4//! Implements GitHub issue #7: <https://github.com/paiml/probar/issues/7>
5//!
6//! ## Features
7//!
8//! - HTTP server with correct MIME types for WASM
9//! - WebSocket server for hot reload notifications
10//! - File watcher with debouncing
11//! - `wasm-pack` build integration
12//!
13//! ## Architecture
14//!
15//! ```text
16//! ┌─────────────────────────────────────────────────────────────┐
17//! │                    DevServer                                │
18//! │  ┌──────────────┐     ┌──────────────┐                     │
19//! │  │ HTTP Server  │     │  WS Server   │                     │
20//! │  │ (port 8080)  │     │ (port 8081)  │                     │
21//! │  └──────┬───────┘     └──────┬───────┘                     │
22//! │         │                    │                              │
23//! │         ▼                    ▼                              │
24//! │  ┌──────────────┐     ┌──────────────┐                     │
25//! │  │ Static Files │     │ HotReload    │◀───FileWatcher      │
26//! │  │ .wasm .js    │     │ Messages     │                     │
27//! │  └──────────────┘     └──────────────┘                     │
28//! └─────────────────────────────────────────────────────────────┘
29//! ```
30
31// Clippy allows for server patterns
32#![allow(clippy::unused_async)]
33#![allow(clippy::unnested_or_patterns)]
34#![allow(clippy::use_self)]
35#![allow(clippy::missing_errors_doc)]
36#![allow(clippy::missing_const_for_fn)]
37#![allow(clippy::doc_markdown)]
38#![allow(clippy::redundant_else)]
39#![allow(clippy::cast_possible_truncation)]
40#![allow(clippy::cast_precision_loss)]
41
42use axum::{
43    extract::ws::{Message, WebSocket, WebSocketUpgrade},
44    http::{header, StatusCode},
45    response::{IntoResponse, Response},
46    routing::get,
47    Router,
48};
49use futures::{SinkExt, StreamExt};
50use serde::{Deserialize, Serialize};
51use std::net::SocketAddr;
52use std::path::PathBuf;
53use std::sync::Arc;
54use tokio::sync::broadcast;
55use tower_http::compression::CompressionLayer;
56use tower_http::cors::{Any, CorsLayer};
57
58/// Hot reload message sent to connected clients (JSON serializable)
59#[derive(Clone, Debug, Serialize, Deserialize)]
60#[serde(tag = "type", content = "data")]
61pub enum HotReloadMessage {
62    /// File changed, rebuild triggered
63    FileChanged {
64        /// Path to the changed file
65        path: String,
66    },
67    /// Rebuild started
68    RebuildStarted,
69    /// Rebuild completed successfully
70    RebuildComplete {
71        /// Duration in milliseconds
72        duration_ms: u64,
73    },
74    /// Rebuild failed with error
75    RebuildFailed {
76        /// Error message
77        error: String,
78    },
79    /// Server ready
80    ServerReady,
81    /// Enhanced file change with size tracking (spec C.2)
82    FileModified {
83        /// Path to the changed file
84        path: String,
85        /// Event type (created, modified, deleted, renamed)
86        event: FileChangeEvent,
87        /// Timestamp in milliseconds since epoch
88        timestamp: u64,
89        /// Size before change (None for created files)
90        size_before: Option<u64>,
91        /// Size after change (None for deleted files)
92        size_after: Option<u64>,
93        /// Human-readable diff summary
94        diff_summary: String,
95    },
96    /// Client connected notification
97    ClientConnected {
98        /// Number of connected clients
99        client_count: usize,
100    },
101    /// Client disconnected notification
102    ClientDisconnected {
103        /// Number of connected clients
104        client_count: usize,
105    },
106}
107
108/// File change event types
109#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
110#[serde(rename_all = "lowercase")]
111pub enum FileChangeEvent {
112    /// New file created
113    Created,
114    /// Existing file modified
115    Modified,
116    /// File deleted
117    Deleted,
118    /// File renamed
119    Renamed,
120}
121
122impl HotReloadMessage {
123    /// Serialize to JSON for WebSocket transmission
124    #[must_use]
125    pub fn to_json(&self) -> String {
126        serde_json::to_string(self).unwrap_or_else(|_| r#"{"type":"Error"}"#.to_string())
127    }
128
129    /// Create a file modified message with size tracking
130    #[must_use]
131    pub fn file_modified(
132        path: impl Into<String>,
133        event: FileChangeEvent,
134        size_before: Option<u64>,
135        size_after: Option<u64>,
136    ) -> Self {
137        use std::time::{SystemTime, UNIX_EPOCH};
138
139        let timestamp = SystemTime::now()
140            .duration_since(UNIX_EPOCH)
141            .map(|d| d.as_millis() as u64)
142            .unwrap_or(0);
143
144        let diff_summary = match (&event, size_before, size_after) {
145            (FileChangeEvent::Created, _, Some(after)) => format!("+{}", format_bytes(after)),
146            (FileChangeEvent::Deleted, Some(before), _) => format!("-{}", format_bytes(before)),
147            (FileChangeEvent::Modified, Some(before), Some(after)) => {
148                if after >= before {
149                    format!("+{}", format_bytes(after - before))
150                } else {
151                    format!("-{}", format_bytes(before - after))
152                }
153            }
154            (FileChangeEvent::Renamed, _, _) => "renamed".to_string(),
155            _ => "changed".to_string(),
156        };
157
158        Self::FileModified {
159            path: path.into(),
160            event,
161            timestamp,
162            size_before,
163            size_after,
164            diff_summary,
165        }
166    }
167}
168
169impl FileChangeEvent {
170    /// Get display string for the event type
171    #[must_use]
172    pub const fn as_str(&self) -> &'static str {
173        match self {
174            Self::Created => "CREATED",
175            Self::Modified => "MODIFIED",
176            Self::Deleted => "DELETED",
177            Self::Renamed => "RENAMED",
178        }
179    }
180}
181
182/// Format bytes in human-readable form
183fn format_bytes(bytes: u64) -> String {
184    const KB: u64 = 1024;
185    const MB: u64 = KB * 1024;
186
187    if bytes >= MB {
188        format!("{:.1} MB", bytes as f64 / MB as f64)
189    } else if bytes >= KB {
190        format!("{:.1} KB", bytes as f64 / KB as f64)
191    } else {
192        format!("{bytes} bytes")
193    }
194}
195
196/// WASM development server configuration
197#[derive(Debug, Clone)]
198pub struct DevServerConfig {
199    /// Directory to serve static files from
200    pub directory: PathBuf,
201    /// HTTP port
202    pub port: u16,
203    /// WebSocket port for hot reload
204    pub ws_port: u16,
205    /// Enable CORS
206    pub cors: bool,
207    /// Enable Cross-Origin Isolation (COOP/COEP headers for SharedArrayBuffer)
208    pub cross_origin_isolated: bool,
209}
210
211impl Default for DevServerConfig {
212    fn default() -> Self {
213        Self {
214            directory: PathBuf::from("."),
215            port: 8080,
216            ws_port: 8081,
217            cors: false,
218            cross_origin_isolated: false,
219        }
220    }
221}
222
223impl DevServerConfig {
224    /// Create a builder
225    #[must_use]
226    pub fn builder() -> DevServerConfigBuilder {
227        DevServerConfigBuilder::default()
228    }
229}
230
231/// Builder for `DevServerConfig`
232#[derive(Debug, Clone, Default)]
233pub struct DevServerConfigBuilder {
234    config: DevServerConfig,
235}
236
237impl DevServerConfigBuilder {
238    /// Set directory to serve
239    #[must_use]
240    pub fn directory(mut self, dir: impl Into<PathBuf>) -> Self {
241        self.config.directory = dir.into();
242        self
243    }
244
245    /// Set HTTP port
246    #[must_use]
247    pub fn port(mut self, port: u16) -> Self {
248        self.config.port = port;
249        self
250    }
251
252    /// Set WebSocket port
253    #[must_use]
254    pub fn ws_port(mut self, port: u16) -> Self {
255        self.config.ws_port = port;
256        self
257    }
258
259    /// Enable CORS
260    #[must_use]
261    pub fn cors(mut self, enabled: bool) -> Self {
262        self.config.cors = enabled;
263        self
264    }
265
266    /// Enable Cross-Origin Isolation (COOP/COEP headers)
267    ///
268    /// Required for SharedArrayBuffer and parallel WASM with Web Workers.
269    /// Sets the following headers:
270    /// - `Cross-Origin-Opener-Policy: same-origin`
271    /// - `Cross-Origin-Embedder-Policy: require-corp`
272    #[must_use]
273    pub fn cross_origin_isolated(mut self, enabled: bool) -> Self {
274        self.config.cross_origin_isolated = enabled;
275        self
276    }
277
278    /// Build the configuration
279    #[must_use]
280    pub fn build(self) -> DevServerConfig {
281        self.config
282    }
283}
284
285/// WASM development server with HTTP and WebSocket support
286#[derive(Debug)]
287pub struct DevServer {
288    config: DevServerConfig,
289    reload_tx: broadcast::Sender<HotReloadMessage>,
290}
291
292impl DevServer {
293    /// Create a new dev server
294    #[must_use]
295    pub fn new(config: DevServerConfig) -> Self {
296        let (reload_tx, _) = broadcast::channel(64);
297        Self { config, reload_tx }
298    }
299
300    /// Get a sender for hot reload messages
301    #[must_use]
302    pub fn reload_sender(&self) -> broadcast::Sender<HotReloadMessage> {
303        self.reload_tx.clone()
304    }
305
306    /// Get the HTTP URL
307    #[must_use]
308    pub fn http_url(&self) -> String {
309        format!("http://localhost:{}", self.config.port)
310    }
311
312    /// Get the WebSocket URL
313    #[must_use]
314    pub fn ws_url(&self) -> String {
315        format!("ws://localhost:{}/ws", self.config.port)
316    }
317
318    /// Start the server (blocking)
319    ///
320    /// This starts both the HTTP server for static files and
321    /// WebSocket endpoints for hot reload on the same port.
322    pub async fn run(&self) -> Result<(), std::io::Error> {
323        let directory = Arc::new(self.config.directory.clone());
324        let reload_tx = self.reload_tx.clone();
325
326        // Build router with static file serving and WebSocket
327        let app = Router::new()
328            // WebSocket endpoint for hot reload
329            .route(
330                "/ws",
331                get({
332                    let tx = reload_tx.clone();
333                    move |ws: WebSocketUpgrade| handle_websocket(ws, tx.clone())
334                }),
335            )
336            // Index route
337            .route(
338                "/",
339                get({
340                    let dir = directory.clone();
341                    move || serve_index(dir.clone())
342                }),
343            )
344            // Static file fallback
345            .fallback({
346                let dir = directory.clone();
347                move |uri: axum::http::Uri| serve_static(dir.clone(), uri)
348            });
349
350        // Add CORS if enabled
351        let app = if self.config.cors {
352            app.layer(
353                CorsLayer::new()
354                    .allow_origin(Any)
355                    .allow_methods(Any)
356                    .allow_headers(Any),
357            )
358        } else {
359            app
360        };
361
362        // Add Cross-Origin Isolation headers if enabled (for SharedArrayBuffer/Web Workers)
363        let app = if self.config.cross_origin_isolated {
364            use tower_http::set_header::SetResponseHeaderLayer;
365            app.layer(SetResponseHeaderLayer::overriding(
366                header::HeaderName::from_static("cross-origin-opener-policy"),
367                header::HeaderValue::from_static("same-origin"),
368            ))
369            .layer(SetResponseHeaderLayer::overriding(
370                header::HeaderName::from_static("cross-origin-embedder-policy"),
371                header::HeaderValue::from_static("require-corp"),
372            ))
373        } else {
374            app
375        };
376
377        // Add gzip compression for better WASM transfer speeds
378        let app = app.layer(CompressionLayer::new().gzip(true));
379
380        let addr = SocketAddr::from(([0, 0, 0, 0], self.config.port));
381
382        println!("╔══════════════════════════════════════════════════════════════╗");
383        println!("║               Probar WASM Development Server                 ║");
384        println!("╠══════════════════════════════════════════════════════════════╣");
385        println!("║  HTTP:      http://localhost:{:<29}║", self.config.port);
386        println!(
387            "║  WebSocket: ws://localhost:{}/ws{:<23}║",
388            self.config.port, ""
389        );
390        println!(
391            "║  Directory: {:<48}║",
392            self.config
393                .directory
394                .display()
395                .to_string()
396                .chars()
397                .take(48)
398                .collect::<String>()
399        );
400        println!(
401            "║  CORS:      {:<48}║",
402            if self.config.cors {
403                "enabled"
404            } else {
405                "disabled"
406            }
407        );
408        println!(
409            "║  COOP/COEP: {:<48}║",
410            if self.config.cross_origin_isolated {
411                "enabled (SharedArrayBuffer available)"
412            } else {
413                "disabled"
414            }
415        );
416        println!("║  Gzip:      {:<48}║", "enabled (auto-compression)");
417        println!("╠══════════════════════════════════════════════════════════════╣");
418        println!("║  Press Ctrl+C to stop                                        ║");
419        println!("╚══════════════════════════════════════════════════════════════╝");
420
421        // Notify that server is ready
422        let _ = reload_tx.send(HotReloadMessage::ServerReady);
423
424        let listener = tokio::net::TcpListener::bind(addr).await?;
425        axum::serve(listener, app).await?;
426
427        Ok(())
428    }
429
430    /// Run HTTP and WebSocket servers on separate ports
431    ///
432    /// Use this when you need dedicated ports for HTTP and WebSocket.
433    pub async fn run_split(&self) -> Result<(), std::io::Error> {
434        let directory = Arc::new(self.config.directory.clone());
435        let reload_tx = self.reload_tx.clone();
436
437        // HTTP server
438        let http_app = Router::new()
439            .route(
440                "/",
441                get({
442                    let dir = directory.clone();
443                    move || serve_index(dir.clone())
444                }),
445            )
446            .fallback({
447                let dir = directory.clone();
448                move |uri: axum::http::Uri| serve_static(dir.clone(), uri)
449            });
450
451        let http_app = if self.config.cors {
452            http_app.layer(
453                CorsLayer::new()
454                    .allow_origin(Any)
455                    .allow_methods(Any)
456                    .allow_headers(Any),
457            )
458        } else {
459            http_app
460        };
461
462        // WebSocket server
463        let ws_app = Router::new().route(
464            "/",
465            get({
466                let tx = reload_tx.clone();
467                move |ws: WebSocketUpgrade| handle_websocket(ws, tx.clone())
468            }),
469        );
470
471        let http_addr = SocketAddr::from(([0, 0, 0, 0], self.config.port));
472        let ws_addr = SocketAddr::from(([0, 0, 0, 0], self.config.ws_port));
473
474        println!("╔══════════════════════════════════════════════════════════════╗");
475        println!("║               Probar WASM Development Server                 ║");
476        println!("╠══════════════════════════════════════════════════════════════╣");
477        println!("║  HTTP:      http://localhost:{:<29}║", self.config.port);
478        println!("║  WebSocket: ws://localhost:{:<30}║", self.config.ws_port);
479        println!(
480            "║  Directory: {:<48}║",
481            self.config
482                .directory
483                .display()
484                .to_string()
485                .chars()
486                .take(48)
487                .collect::<String>()
488        );
489        println!("╠══════════════════════════════════════════════════════════════╣");
490        println!("║  Press Ctrl+C to stop                                        ║");
491        println!("╚══════════════════════════════════════════════════════════════╝");
492
493        let _ = reload_tx.send(HotReloadMessage::ServerReady);
494
495        let http_listener = tokio::net::TcpListener::bind(http_addr).await?;
496        let ws_listener = tokio::net::TcpListener::bind(ws_addr).await?;
497
498        tokio::select! {
499            r = axum::serve(http_listener, http_app) => r?,
500            r = axum::serve(ws_listener, ws_app) => r?,
501        }
502
503        Ok(())
504    }
505}
506
507/// Handle WebSocket connection for hot reload
508async fn handle_websocket(
509    ws: WebSocketUpgrade,
510    reload_tx: broadcast::Sender<HotReloadMessage>,
511) -> impl IntoResponse {
512    ws.on_upgrade(move |socket| websocket_handler(socket, reload_tx))
513}
514
515/// WebSocket handler that broadcasts hot reload messages
516async fn websocket_handler(socket: WebSocket, reload_tx: broadcast::Sender<HotReloadMessage>) {
517    let (mut sender, mut receiver) = socket.split();
518    let mut rx = reload_tx.subscribe();
519
520    // Send initial ready message
521    let ready_msg = HotReloadMessage::ServerReady.to_json();
522    if sender.send(Message::Text(ready_msg.into())).await.is_err() {
523        return;
524    }
525
526    // Handle incoming messages and broadcast outgoing
527    loop {
528        tokio::select! {
529            // Forward hot reload messages to the client
530            result = rx.recv() => {
531                match result {
532                    Ok(msg) => {
533                        let json = msg.to_json();
534                        if sender.send(Message::Text(json.into())).await.is_err() {
535                            break;
536                        }
537                    }
538                    Err(_) => break,
539                }
540            }
541            // Handle client messages (ping/pong, close)
542            msg_opt = receiver.next() => {
543                match msg_opt {
544                    Some(Ok(Message::Ping(data))) => {
545                        if sender.send(Message::Pong(data)).await.is_err() {
546                            break;
547                        }
548                    }
549                    Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
550                    _ => {}
551                }
552            }
553        }
554    }
555}
556
557/// Serve index.html
558async fn serve_index(directory: Arc<PathBuf>) -> Response {
559    let index_path = directory.join("index.html");
560    serve_file(&index_path).await
561}
562
563/// Serve static file based on URI
564///
565/// Handles directory requests by serving index.html if it exists.
566/// Properly handles both `/dir/` and `/dir` paths.
567async fn serve_static(directory: Arc<PathBuf>, uri: axum::http::Uri) -> Response {
568    // Trim both leading and trailing slashes to normalize path
569    let path = uri.path().trim_start_matches('/').trim_end_matches('/');
570    let file_path = if path.is_empty() {
571        directory.as_ref().clone()
572    } else {
573        directory.join(path)
574    };
575
576    // If path is a directory, try to serve index.html
577    if file_path.is_dir() {
578        let index_path = file_path.join("index.html");
579        if index_path.exists() {
580            return serve_file(&index_path).await;
581        }
582        // Return 404 for directories without index.html
583        return (
584            StatusCode::NOT_FOUND,
585            format!("No index.html found in directory: {}", file_path.display()),
586        )
587            .into_response();
588    }
589
590    serve_file(&file_path).await
591}
592
593/// Serve a file with correct MIME type
594///
595/// MIME types are critical for WASM to work in browsers:
596/// - `.wasm` files MUST be `application/wasm`
597/// - `.js` files MUST be `text/javascript` or `application/javascript`
598async fn serve_file(path: &std::path::Path) -> Response {
599    match tokio::fs::read(path).await {
600        Ok(contents) => {
601            // Determine MIME type from extension
602            let mime_type = get_mime_type(path);
603
604            Response::builder()
605                .status(StatusCode::OK)
606                .header(header::CONTENT_TYPE, mime_type)
607                .header(header::CACHE_CONTROL, "no-cache")
608                .body(axum::body::Body::from(contents))
609                .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
610        }
611        Err(e) if e.kind() == std::io::ErrorKind::NotFound => (
612            StatusCode::NOT_FOUND,
613            format!("File not found: {}", path.display()),
614        )
615            .into_response(),
616        Err(e) => (
617            StatusCode::INTERNAL_SERVER_ERROR,
618            format!("Error reading file: {e}"),
619        )
620            .into_response(),
621    }
622}
623
624/// Get MIME type for a file path
625///
626/// Ensures WASM files get the correct `application/wasm` type
627#[must_use]
628pub fn get_mime_type(path: &std::path::Path) -> String {
629    match path.extension().and_then(|e| e.to_str()) {
630        Some("wasm") => "application/wasm".to_string(),
631        Some("js") | Some("mjs") => "text/javascript".to_string(),
632        Some("html") | Some("htm") => "text/html".to_string(),
633        Some("css") => "text/css".to_string(),
634        Some("json") => "application/json".to_string(),
635        Some("png") => "image/png".to_string(),
636        Some("jpg") | Some("jpeg") => "image/jpeg".to_string(),
637        Some("svg") => "image/svg+xml".to_string(),
638        Some("ico") => "image/x-icon".to_string(),
639        _ => mime_guess::from_path(path)
640            .first_or_octet_stream()
641            .to_string(),
642    }
643}
644
645// =============================================================================
646// WASM Build
647// =============================================================================
648
649/// Run wasm-pack build
650///
651/// This wraps the `wasm-pack` CLI tool to build Rust projects for WASM.
652///
653/// # Arguments
654///
655/// * `path` - Directory containing Cargo.toml
656/// * `target` - WASM target (web, bundler, nodejs, no-modules)
657/// * `release` - Build in release mode
658/// * `out_dir` - Output directory (default: pkg)
659/// * `profiling` - Enable profiling (adds names section)
660///
661/// # Errors
662///
663/// Returns an error if wasm-pack is not installed or build fails.
664pub async fn run_wasm_pack_build(
665    path: &std::path::Path,
666    target: &str,
667    release: bool,
668    out_dir: Option<&std::path::Path>,
669    profiling: bool,
670) -> Result<(), String> {
671    use std::process::Stdio;
672    use std::time::Instant;
673
674    let start = Instant::now();
675
676    let mut cmd = tokio::process::Command::new("wasm-pack");
677    cmd.arg("build");
678    cmd.arg("--target").arg(target);
679
680    if release {
681        cmd.arg("--release");
682    } else {
683        cmd.arg("--dev");
684    }
685
686    if let Some(out) = out_dir {
687        cmd.arg("--out-dir").arg(out);
688    }
689
690    if profiling {
691        cmd.arg("--profiling");
692    }
693
694    cmd.current_dir(path);
695    cmd.stdout(Stdio::inherit());
696    cmd.stderr(Stdio::inherit());
697
698    println!(
699        "Running: wasm-pack build --target {} {}",
700        target,
701        if release { "--release" } else { "--dev" }
702    );
703
704    let status: std::process::ExitStatus = cmd
705        .status()
706        .await
707        .map_err(|e| format!("Failed to execute wasm-pack: {e}. Is wasm-pack installed?"))?;
708
709    let elapsed = start.elapsed();
710
711    if status.success() {
712        println!("Build completed in {:.2}s", elapsed.as_secs_f64());
713        Ok(())
714    } else {
715        Err(format!(
716            "wasm-pack build failed with exit code: {:?}",
717            status.code()
718        ))
719    }
720}
721
722// =============================================================================
723// File Watcher
724// =============================================================================
725
726/// File watcher for hot reload
727///
728/// Watches for changes to Rust source files and triggers rebuilds.
729#[derive(Debug)]
730pub struct FileWatcher {
731    /// Directory to watch
732    pub path: PathBuf,
733    /// Debounce interval in milliseconds
734    pub debounce_ms: u64,
735    /// File patterns to watch (extensions)
736    pub patterns: Vec<String>,
737}
738
739impl FileWatcher {
740    /// Create a new file watcher with default settings
741    #[must_use]
742    pub fn new(path: PathBuf, debounce_ms: u64) -> Self {
743        Self {
744            path,
745            debounce_ms,
746            patterns: vec!["rs".to_string(), "toml".to_string()],
747        }
748    }
749
750    /// Create a builder
751    #[must_use]
752    pub fn builder() -> FileWatcherBuilder {
753        FileWatcherBuilder::default()
754    }
755
756    /// Check if a path matches watch patterns
757    #[must_use]
758    pub fn matches_pattern(&self, path: &std::path::Path) -> bool {
759        path.extension()
760            .and_then(|e| e.to_str())
761            .is_some_and(|ext| self.patterns.iter().any(|p| p == ext))
762    }
763
764    /// Start watching for changes
765    ///
766    /// Calls `on_change` with the path of each changed file.
767    /// This function blocks until the watcher is stopped.
768    pub async fn watch<F>(&self, mut on_change: F) -> Result<(), notify::Error>
769    where
770        F: FnMut(String) + Send + 'static,
771    {
772        use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
773        use std::sync::mpsc;
774        use std::time::Duration;
775
776        let (tx, rx) = mpsc::channel();
777        let patterns = self.patterns.clone();
778
779        let mut watcher = RecommendedWatcher::new(
780            move |res: Result<notify::Event, notify::Error>| {
781                if let Ok(event) = res {
782                    if event.kind.is_modify() || event.kind.is_create() {
783                        for path in event.paths {
784                            let matches = path
785                                .extension()
786                                .and_then(|e| e.to_str())
787                                .is_some_and(|ext| patterns.iter().any(|p| p == ext));
788                            if matches {
789                                let _ = tx.send(path.display().to_string());
790                            }
791                        }
792                    }
793                }
794            },
795            Config::default().with_poll_interval(Duration::from_millis(self.debounce_ms)),
796        )?;
797
798        watcher.watch(&self.path, RecursiveMode::Recursive)?;
799
800        // Keep watcher alive and process events
801        loop {
802            match rx.recv_timeout(Duration::from_millis(100)) {
803                Ok(path) => {
804                    on_change(path);
805                }
806                Err(mpsc::RecvTimeoutError::Timeout) => {}
807                Err(mpsc::RecvTimeoutError::Disconnected) => break,
808            }
809        }
810
811        Ok(())
812    }
813}
814
815/// Builder for `FileWatcher`
816#[derive(Debug, Clone, Default)]
817pub struct FileWatcherBuilder {
818    path: Option<PathBuf>,
819    debounce_ms: u64,
820    patterns: Vec<String>,
821}
822
823impl FileWatcherBuilder {
824    /// Set watch path
825    #[must_use]
826    pub fn path(mut self, path: impl Into<PathBuf>) -> Self {
827        self.path = Some(path.into());
828        self
829    }
830
831    /// Set debounce interval
832    #[must_use]
833    pub fn debounce_ms(mut self, ms: u64) -> Self {
834        self.debounce_ms = ms;
835        self
836    }
837
838    /// Add file pattern to watch
839    #[must_use]
840    pub fn pattern(mut self, ext: impl Into<String>) -> Self {
841        self.patterns.push(ext.into());
842        self
843    }
844
845    /// Build the file watcher
846    #[must_use]
847    pub fn build(self) -> FileWatcher {
848        FileWatcher {
849            path: self.path.unwrap_or_else(|| PathBuf::from(".")),
850            debounce_ms: if self.debounce_ms == 0 {
851                500
852            } else {
853                self.debounce_ms
854            },
855            patterns: if self.patterns.is_empty() {
856                vec!["rs".to_string(), "toml".to_string()]
857            } else {
858                self.patterns
859            },
860        }
861    }
862}
863
864// =============================================================================
865// Module Validation (PROBAR-SPEC-007)
866// =============================================================================
867
868/// Import reference found in HTML/JS files
869#[derive(Debug, Clone)]
870pub struct ImportRef {
871    /// Source file containing the import
872    pub source_file: PathBuf,
873    /// Import path (may be relative or absolute)
874    pub import_path: String,
875    /// Type of import
876    pub import_type: ImportType,
877    /// Line number in source file
878    pub line_number: u32,
879}
880
881/// Type of module import
882#[derive(Debug, Clone, Copy, PartialEq, Eq)]
883pub enum ImportType {
884    /// ES Module import (import ... from '...')
885    EsModule,
886    /// Script src attribute
887    Script,
888    /// WASM file
889    Wasm,
890    /// Worker URL (new Worker('...'))
891    Worker,
892}
893
894impl ImportType {
895    /// Expected MIME types for this import type
896    #[must_use]
897    pub fn expected_mime_types(&self) -> &[&str] {
898        match self {
899            Self::EsModule | Self::Script | Self::Worker => {
900                &["text/javascript", "application/javascript"]
901            }
902            Self::Wasm => &["application/wasm"],
903        }
904    }
905}
906
907/// Validation error for a single import
908#[derive(Debug, Clone)]
909pub struct ImportValidationError {
910    /// The import that failed
911    pub import: ImportRef,
912    /// HTTP status received
913    pub status: u16,
914    /// MIME type received
915    pub actual_mime: String,
916    /// Error description
917    pub message: String,
918}
919
920/// Result of validating all imports
921#[derive(Debug, Default)]
922pub struct ModuleValidationResult {
923    /// Total imports scanned
924    pub total_imports: usize,
925    /// Imports that passed validation
926    pub passed: usize,
927    /// Imports that failed validation
928    pub errors: Vec<ImportValidationError>,
929}
930
931impl ModuleValidationResult {
932    /// Check if all validations passed
933    #[must_use]
934    pub fn is_ok(&self) -> bool {
935        self.errors.is_empty()
936    }
937}
938
939/// Module validator for checking import resolution
940#[derive(Debug)]
941pub struct ModuleValidator {
942    /// Root directory being served
943    serve_root: PathBuf,
944    /// Directories to exclude from validation (e.g., node_modules)
945    exclude: Vec<String>,
946}
947
948impl ModuleValidator {
949    /// Create a new module validator
950    #[must_use]
951    pub fn new(serve_root: impl Into<PathBuf>) -> Self {
952        Self {
953            serve_root: serve_root.into(),
954            exclude: vec!["node_modules".to_string()], // Default exclusion
955        }
956    }
957
958    /// Set directories to exclude from validation
959    #[must_use]
960    pub fn with_exclude(mut self, exclude: Vec<String>) -> Self {
961        self.exclude = exclude;
962        self
963    }
964
965    /// Check if a path should be excluded from validation
966    fn is_excluded(&self, path: &std::path::Path) -> bool {
967        let path_str = path.to_string_lossy();
968        self.exclude.iter().any(|excl| {
969            path_str.contains(&format!("/{excl}/")) || path_str.contains(&format!("\\{excl}\\"))
970        })
971    }
972
973    /// Scan all HTML files and extract module imports
974    #[must_use]
975    pub fn scan_imports(&self) -> Vec<ImportRef> {
976        let mut imports = Vec::new();
977
978        // Find all HTML files
979        let pattern = self.serve_root.join("**/*.html");
980        if let Ok(paths) = glob::glob(&pattern.to_string_lossy()) {
981            for entry in paths.flatten() {
982                // Skip excluded directories
983                if self.is_excluded(&entry) {
984                    continue;
985                }
986                if let Ok(content) = std::fs::read_to_string(&entry) {
987                    imports.extend(Self::extract_imports_from_html(&entry, &content));
988                }
989            }
990        }
991
992        imports
993    }
994
995    /// Extract imports from HTML content
996    fn extract_imports_from_html(file: &std::path::Path, content: &str) -> Vec<ImportRef> {
997        let mut imports = Vec::new();
998
999        for (line_num, line) in content.lines().enumerate() {
1000            let line_number = (line_num + 1) as u32;
1001
1002            // ES Module imports: import ... from '...' or import '...'
1003            if let Some(path) = Self::extract_es_import(line) {
1004                imports.push(ImportRef {
1005                    source_file: file.to_path_buf(),
1006                    import_path: path,
1007                    import_type: ImportType::EsModule,
1008                    line_number,
1009                });
1010            }
1011
1012            // Script src: <script src="...">
1013            if let Some(path) = Self::extract_script_src(line) {
1014                // Skip inline module scripts with type="module" (handled by ES import)
1015                if !line.contains("type=\"module\"") || line.contains("src=") {
1016                    imports.push(ImportRef {
1017                        source_file: file.to_path_buf(),
1018                        import_path: path,
1019                        import_type: ImportType::Script,
1020                        line_number,
1021                    });
1022                }
1023            }
1024
1025            // Worker URLs: new Worker('...')
1026            if let Some(path) = Self::extract_worker_url(line) {
1027                imports.push(ImportRef {
1028                    source_file: file.to_path_buf(),
1029                    import_path: path,
1030                    import_type: ImportType::Worker,
1031                    line_number,
1032                });
1033            }
1034        }
1035
1036        imports
1037    }
1038
1039    /// Check if path has a JS/WASM extension (case-insensitive)
1040    fn has_js_or_wasm_extension(path: &str) -> bool {
1041        let path = std::path::Path::new(path);
1042        path.extension()
1043            .and_then(|ext| ext.to_str())
1044            .is_some_and(|ext| {
1045                let ext_lower = ext.to_ascii_lowercase();
1046                ext_lower == "js" || ext_lower == "mjs" || ext_lower == "wasm"
1047            })
1048    }
1049
1050    /// Check if path has a JS extension (case-insensitive)
1051    fn has_js_extension(path: &str) -> bool {
1052        let path = std::path::Path::new(path);
1053        path.extension()
1054            .and_then(|ext| ext.to_str())
1055            .is_some_and(|ext| {
1056                let ext_lower = ext.to_ascii_lowercase();
1057                ext_lower == "js" || ext_lower == "mjs"
1058            })
1059    }
1060
1061    /// Extract ES module import path
1062    fn extract_es_import(line: &str) -> Option<String> {
1063        // Match: import ... from '...' or import ... from "..."
1064        let patterns = [
1065            (r"from '", "'"),
1066            (r#"from ""#, "\""),
1067            // Also match dynamic import()
1068            (r"import('", "'"),
1069            (r#"import(""#, "\""),
1070        ];
1071
1072        for (start, end) in patterns {
1073            if let Some(idx) = line.find(start) {
1074                let rest = &line[idx + start.len()..];
1075                if let Some(end_idx) = rest.find(end) {
1076                    let path = &rest[..end_idx];
1077                    // Only include JS/WASM paths
1078                    if Self::has_js_or_wasm_extension(path) {
1079                        return Some(path.to_string());
1080                    }
1081                }
1082            }
1083        }
1084
1085        None
1086    }
1087
1088    /// Extract script src attribute
1089    fn extract_script_src(line: &str) -> Option<String> {
1090        let patterns = [(r#"src=""#, "\""), (r"src='", "'")];
1091
1092        for (start, end) in patterns {
1093            if let Some(idx) = line.find(start) {
1094                let rest = &line[idx + start.len()..];
1095                if let Some(end_idx) = rest.find(end) {
1096                    let path = &rest[..end_idx];
1097                    if Self::has_js_extension(path) {
1098                        return Some(path.to_string());
1099                    }
1100                }
1101            }
1102        }
1103
1104        None
1105    }
1106
1107    /// Extract Worker URL
1108    fn extract_worker_url(line: &str) -> Option<String> {
1109        let patterns = [(r"new Worker('", "'"), (r#"new Worker(""#, "\"")];
1110
1111        for (start, end) in patterns {
1112            if let Some(idx) = line.find(start) {
1113                let rest = &line[idx + start.len()..];
1114                if let Some(end_idx) = rest.find(end) {
1115                    let path = &rest[..end_idx];
1116                    return Some(path.to_string());
1117                }
1118            }
1119        }
1120
1121        None
1122    }
1123
1124    /// Resolve import path relative to source file
1125    fn resolve_path(&self, import: &ImportRef) -> Option<PathBuf> {
1126        let import_path = &import.import_path;
1127
1128        if import_path.starts_with('/') {
1129            // Absolute path from serve root
1130            Some(self.serve_root.join(import_path.trim_start_matches('/')))
1131        } else if import_path.starts_with("./") || import_path.starts_with("../") {
1132            // Relative path from source file
1133            let source_dir = import.source_file.parent()?;
1134            Some(source_dir.join(import_path))
1135        } else {
1136            // Bare specifier or absolute URL - skip validation
1137            None
1138        }
1139    }
1140
1141    /// Validate all imports resolve correctly
1142    #[must_use]
1143    pub fn validate(&self) -> ModuleValidationResult {
1144        let imports = self.scan_imports();
1145        let mut result = ModuleValidationResult {
1146            total_imports: imports.len(),
1147            ..Default::default()
1148        };
1149
1150        for import in imports {
1151            if let Some(resolved) = self.resolve_path(&import) {
1152                // Check if file exists
1153                let canonical = resolved.canonicalize();
1154
1155                match canonical {
1156                    Ok(path) if path.exists() => {
1157                        // Check MIME type
1158                        let mime = get_mime_type(&path);
1159                        let expected = import.import_type.expected_mime_types();
1160
1161                        if expected.iter().any(|&e| mime.starts_with(e)) {
1162                            result.passed += 1;
1163                        } else {
1164                            result.errors.push(ImportValidationError {
1165                                import: import.clone(),
1166                                status: 200,
1167                                actual_mime: mime.clone(),
1168                                message: format!(
1169                                    "MIME type mismatch: expected {expected:?}, got '{mime}'"
1170                                ),
1171                            });
1172                        }
1173                    }
1174                    _ => {
1175                        result.errors.push(ImportValidationError {
1176                            import: import.clone(),
1177                            status: 404,
1178                            actual_mime: "text/plain".to_string(),
1179                            message: format!(
1180                                "File not found: {} (resolved to {})",
1181                                import.import_path,
1182                                resolved.display()
1183                            ),
1184                        });
1185                    }
1186                }
1187            } else {
1188                // External or unresolvable - count as passed
1189                result.passed += 1;
1190            }
1191        }
1192
1193        result
1194    }
1195
1196    /// Print validation results to stderr
1197    pub fn print_results(&self, result: &ModuleValidationResult) {
1198        eprintln!("\nValidating module imports...");
1199        eprintln!("  Scanned: {} imports", result.total_imports);
1200        eprintln!("  Passed:  {}", result.passed);
1201        eprintln!("  Failed:  {}", result.errors.len());
1202
1203        if !result.errors.is_empty() {
1204            eprintln!("\nErrors:");
1205            for error in &result.errors {
1206                eprintln!(
1207                    "  {} {}:{}",
1208                    if error.status == 404 { "✗" } else { "⚠" },
1209                    error.import.source_file.display(),
1210                    error.import.line_number
1211                );
1212                eprintln!("    Import: {}", error.import.import_path);
1213                eprintln!("    {}", error.message);
1214            }
1215        }
1216    }
1217}
1218
1219// =============================================================================
1220// Tests
1221// =============================================================================
1222
1223#[cfg(test)]
1224#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1225mod tests {
1226    use super::*;
1227
1228    // =========================================================================
1229    // DevServerConfig Tests
1230    // =========================================================================
1231
1232    #[test]
1233    fn test_dev_server_config_default() {
1234        let config = DevServerConfig::default();
1235        assert_eq!(config.port, 8080);
1236        assert_eq!(config.ws_port, 8081);
1237        assert!(!config.cors);
1238        assert!(!config.cross_origin_isolated);
1239        assert_eq!(config.directory, PathBuf::from("."));
1240    }
1241
1242    #[test]
1243    fn test_dev_server_config_builder() {
1244        let config = DevServerConfig::builder()
1245            .directory("./www")
1246            .port(9000)
1247            .ws_port(9001)
1248            .cors(true)
1249            .build();
1250
1251        assert_eq!(config.port, 9000);
1252        assert_eq!(config.ws_port, 9001);
1253        assert!(config.cors);
1254        assert_eq!(config.directory, PathBuf::from("./www"));
1255    }
1256
1257    #[test]
1258    fn test_dev_server_config_cross_origin_isolated() {
1259        let config = DevServerConfig::builder()
1260            .cross_origin_isolated(true)
1261            .build();
1262
1263        assert!(config.cross_origin_isolated);
1264    }
1265
1266    // =========================================================================
1267    // DevServer Tests
1268    // =========================================================================
1269
1270    #[test]
1271    fn test_dev_server_creation() {
1272        let config = DevServerConfig {
1273            directory: PathBuf::from("./www"),
1274            port: 9000,
1275            ws_port: 9001,
1276            cors: true,
1277            cross_origin_isolated: false,
1278        };
1279        let server = DevServer::new(config);
1280        assert_eq!(server.http_url(), "http://localhost:9000");
1281        assert_eq!(server.ws_url(), "ws://localhost:9000/ws");
1282    }
1283
1284    #[test]
1285    fn test_dev_server_reload_sender() {
1286        let server = DevServer::new(DevServerConfig::default());
1287        let tx = server.reload_sender();
1288
1289        // Should be able to send messages
1290        let result = tx.send(HotReloadMessage::ServerReady);
1291        // No receivers yet, but send should work
1292        assert!(result.is_ok() || result.is_err());
1293    }
1294
1295    // =========================================================================
1296    // HotReloadMessage Tests
1297    // =========================================================================
1298
1299    #[test]
1300    fn test_hot_reload_message_clone() {
1301        let msg = HotReloadMessage::RebuildComplete { duration_ms: 500 };
1302        let cloned = msg;
1303        assert!(matches!(
1304            cloned,
1305            HotReloadMessage::RebuildComplete { duration_ms: 500 }
1306        ));
1307    }
1308
1309    #[test]
1310    fn test_hot_reload_message_to_json() {
1311        let msg = HotReloadMessage::FileChanged {
1312            path: "src/lib.rs".to_string(),
1313        };
1314        let json = msg.to_json();
1315        assert!(json.contains("FileChanged"));
1316        assert!(json.contains("src/lib.rs"));
1317    }
1318
1319    #[test]
1320    fn test_hot_reload_message_rebuild_complete_json() {
1321        let msg = HotReloadMessage::RebuildComplete { duration_ms: 1234 };
1322        let json = msg.to_json();
1323        assert!(json.contains("RebuildComplete"));
1324        assert!(json.contains("1234"));
1325    }
1326
1327    #[test]
1328    fn test_hot_reload_message_rebuild_failed_json() {
1329        let msg = HotReloadMessage::RebuildFailed {
1330            error: "compile error".to_string(),
1331        };
1332        let json = msg.to_json();
1333        assert!(json.contains("RebuildFailed"));
1334        assert!(json.contains("compile error"));
1335    }
1336
1337    #[test]
1338    fn test_hot_reload_message_server_ready_json() {
1339        let msg = HotReloadMessage::ServerReady;
1340        let json = msg.to_json();
1341        assert!(json.contains("ServerReady"));
1342    }
1343
1344    #[test]
1345    fn test_hot_reload_message_rebuild_started_json() {
1346        let msg = HotReloadMessage::RebuildStarted;
1347        let json = msg.to_json();
1348        assert!(json.contains("RebuildStarted"));
1349    }
1350
1351    // =========================================================================
1352    // FileWatcher Tests
1353    // =========================================================================
1354
1355    #[test]
1356    fn test_file_watcher_creation() {
1357        let watcher = FileWatcher::new(PathBuf::from("."), 500);
1358        assert_eq!(watcher.debounce_ms, 500);
1359        assert_eq!(watcher.path, PathBuf::from("."));
1360        assert!(watcher.patterns.contains(&"rs".to_string()));
1361        assert!(watcher.patterns.contains(&"toml".to_string()));
1362    }
1363
1364    #[test]
1365    fn test_file_watcher_builder() {
1366        let watcher = FileWatcher::builder()
1367            .path("./src")
1368            .debounce_ms(1000)
1369            .pattern("rs")
1370            .pattern("ts")
1371            .build();
1372
1373        assert_eq!(watcher.path, PathBuf::from("./src"));
1374        assert_eq!(watcher.debounce_ms, 1000);
1375        assert!(watcher.patterns.contains(&"rs".to_string()));
1376        assert!(watcher.patterns.contains(&"ts".to_string()));
1377    }
1378
1379    #[test]
1380    fn test_file_watcher_builder_defaults() {
1381        let watcher = FileWatcher::builder().build();
1382
1383        assert_eq!(watcher.path, PathBuf::from("."));
1384        assert_eq!(watcher.debounce_ms, 500);
1385        assert!(watcher.patterns.contains(&"rs".to_string()));
1386    }
1387
1388    #[test]
1389    fn test_file_watcher_matches_pattern() {
1390        let watcher = FileWatcher::new(PathBuf::from("."), 500);
1391
1392        assert!(watcher.matches_pattern(&PathBuf::from("src/lib.rs")));
1393        assert!(watcher.matches_pattern(&PathBuf::from("Cargo.toml")));
1394        assert!(!watcher.matches_pattern(&PathBuf::from("README.md")));
1395        assert!(!watcher.matches_pattern(&PathBuf::from("main.js")));
1396    }
1397
1398    #[test]
1399    fn test_file_watcher_custom_patterns() {
1400        let mut watcher = FileWatcher::new(PathBuf::from("."), 500);
1401        watcher.patterns = vec!["js".to_string(), "ts".to_string()];
1402
1403        assert!(watcher.matches_pattern(&PathBuf::from("app.js")));
1404        assert!(watcher.matches_pattern(&PathBuf::from("app.ts")));
1405        assert!(!watcher.matches_pattern(&PathBuf::from("lib.rs")));
1406    }
1407
1408    // =========================================================================
1409    // MIME Type Tests
1410    // =========================================================================
1411
1412    #[test]
1413    fn test_get_mime_type_wasm() {
1414        assert_eq!(
1415            get_mime_type(&PathBuf::from("app.wasm")),
1416            "application/wasm"
1417        );
1418    }
1419
1420    #[test]
1421    fn test_get_mime_type_javascript() {
1422        assert_eq!(get_mime_type(&PathBuf::from("app.js")), "text/javascript");
1423        assert_eq!(
1424            get_mime_type(&PathBuf::from("module.mjs")),
1425            "text/javascript"
1426        );
1427    }
1428
1429    #[test]
1430    fn test_get_mime_type_html() {
1431        assert_eq!(get_mime_type(&PathBuf::from("index.html")), "text/html");
1432        assert_eq!(get_mime_type(&PathBuf::from("page.htm")), "text/html");
1433    }
1434
1435    #[test]
1436    fn test_get_mime_type_css() {
1437        assert_eq!(get_mime_type(&PathBuf::from("styles.css")), "text/css");
1438    }
1439
1440    #[test]
1441    fn test_get_mime_type_json() {
1442        assert_eq!(
1443            get_mime_type(&PathBuf::from("data.json")),
1444            "application/json"
1445        );
1446    }
1447
1448    #[test]
1449    fn test_get_mime_type_images() {
1450        assert_eq!(get_mime_type(&PathBuf::from("logo.png")), "image/png");
1451        assert_eq!(get_mime_type(&PathBuf::from("photo.jpg")), "image/jpeg");
1452        assert_eq!(get_mime_type(&PathBuf::from("photo.jpeg")), "image/jpeg");
1453        assert_eq!(get_mime_type(&PathBuf::from("icon.svg")), "image/svg+xml");
1454        assert_eq!(get_mime_type(&PathBuf::from("favicon.ico")), "image/x-icon");
1455    }
1456
1457    #[test]
1458    fn test_get_mime_type_unknown() {
1459        // Unknown types fall back to mime_guess or octet-stream
1460        let mime = get_mime_type(&PathBuf::from("data.xyz"));
1461        assert!(!mime.is_empty());
1462    }
1463
1464    // =========================================================================
1465    // Directory Index Tests (Bug fix: serve index.html for directories)
1466    // =========================================================================
1467
1468    #[tokio::test]
1469    async fn test_serve_static_directory_serves_index_html() {
1470        use std::sync::Arc;
1471        use tempfile::TempDir;
1472
1473        // Create temp directory with index.html
1474        let temp_dir = TempDir::new().unwrap();
1475        let subdir = temp_dir.path().join("subdir");
1476        std::fs::create_dir(&subdir).unwrap();
1477        std::fs::write(subdir.join("index.html"), "<html>test</html>").unwrap();
1478
1479        let directory = Arc::new(temp_dir.path().to_path_buf());
1480        let uri: axum::http::Uri = "/subdir/".parse().unwrap();
1481
1482        let response = serve_static(directory, uri).await;
1483        assert_eq!(response.status(), StatusCode::OK);
1484    }
1485
1486    #[tokio::test]
1487    async fn test_serve_static_directory_without_trailing_slash() {
1488        use std::sync::Arc;
1489        use tempfile::TempDir;
1490
1491        let temp_dir = TempDir::new().unwrap();
1492        let subdir = temp_dir.path().join("mydir");
1493        std::fs::create_dir(&subdir).unwrap();
1494        std::fs::write(subdir.join("index.html"), "<html>works</html>").unwrap();
1495
1496        let directory = Arc::new(temp_dir.path().to_path_buf());
1497        let uri: axum::http::Uri = "/mydir".parse().unwrap();
1498
1499        let response = serve_static(directory, uri).await;
1500        assert_eq!(response.status(), StatusCode::OK);
1501    }
1502
1503    #[tokio::test]
1504    async fn test_serve_static_directory_no_index_returns_error() {
1505        use std::sync::Arc;
1506        use tempfile::TempDir;
1507
1508        let temp_dir = TempDir::new().unwrap();
1509        let subdir = temp_dir.path().join("empty");
1510        std::fs::create_dir(&subdir).unwrap();
1511        // No index.html
1512
1513        let directory = Arc::new(temp_dir.path().to_path_buf());
1514        let uri: axum::http::Uri = "/empty/".parse().unwrap();
1515
1516        let response = serve_static(directory, uri).await;
1517        // Should return error since directory has no index.html
1518        assert!(response.status().is_client_error() || response.status().is_server_error());
1519    }
1520
1521    // =========================================================================
1522    // Integration-style Tests (no actual I/O)
1523    // =========================================================================
1524
1525    #[test]
1526    fn test_dev_server_config_chain() {
1527        let config = DevServerConfig::builder()
1528            .directory("./dist")
1529            .port(3000)
1530            .ws_port(3001)
1531            .cors(true)
1532            .build();
1533
1534        let server = DevServer::new(config);
1535        assert_eq!(server.http_url(), "http://localhost:3000");
1536    }
1537
1538    #[test]
1539    fn test_file_watcher_builder_chain() {
1540        let watcher = FileWatcher::builder()
1541            .path("./crate")
1542            .debounce_ms(250)
1543            .pattern("rs")
1544            .pattern("toml")
1545            .pattern("lock")
1546            .build();
1547
1548        assert_eq!(watcher.path, PathBuf::from("./crate"));
1549        assert_eq!(watcher.debounce_ms, 250);
1550        assert_eq!(watcher.patterns.len(), 3);
1551    }
1552
1553    // =========================================================================
1554    // Serialization Tests
1555    // =========================================================================
1556
1557    #[test]
1558    fn test_hot_reload_message_roundtrip() {
1559        let original = HotReloadMessage::RebuildComplete { duration_ms: 1500 };
1560        let json = original.to_json();
1561        let parsed: HotReloadMessage = serde_json::from_str(&json).expect("parse failed");
1562
1563        match parsed {
1564            HotReloadMessage::RebuildComplete { duration_ms } => {
1565                assert_eq!(duration_ms, 1500);
1566            }
1567            _ => panic!("Wrong variant after roundtrip"),
1568        }
1569    }
1570
1571    #[test]
1572    fn test_hot_reload_message_all_variants() {
1573        let variants = vec![
1574            HotReloadMessage::FileChanged {
1575                path: "test.rs".to_string(),
1576            },
1577            HotReloadMessage::RebuildStarted,
1578            HotReloadMessage::RebuildComplete { duration_ms: 100 },
1579            HotReloadMessage::RebuildFailed {
1580                error: "err".to_string(),
1581            },
1582            HotReloadMessage::ServerReady,
1583        ];
1584
1585        for variant in variants {
1586            let json = variant.to_json();
1587            assert!(!json.is_empty());
1588            // Verify it can be parsed back
1589            let _: HotReloadMessage = serde_json::from_str(&json).expect("parse failed");
1590        }
1591    }
1592
1593    // =========================================================================
1594    // FileChangeEvent Tests (Phase 4 - Hot Reload Enhancements)
1595    // =========================================================================
1596
1597    #[test]
1598    fn test_file_change_event_as_str() {
1599        assert_eq!(FileChangeEvent::Created.as_str(), "CREATED");
1600        assert_eq!(FileChangeEvent::Modified.as_str(), "MODIFIED");
1601        assert_eq!(FileChangeEvent::Deleted.as_str(), "DELETED");
1602        assert_eq!(FileChangeEvent::Renamed.as_str(), "RENAMED");
1603    }
1604
1605    #[test]
1606    fn test_file_modified_created() {
1607        let msg = HotReloadMessage::file_modified(
1608            "new_file.rs",
1609            FileChangeEvent::Created,
1610            None,
1611            Some(1024),
1612        );
1613
1614        match msg {
1615            HotReloadMessage::FileModified {
1616                path,
1617                event,
1618                diff_summary,
1619                size_after,
1620                ..
1621            } => {
1622                assert_eq!(path, "new_file.rs");
1623                assert_eq!(event, FileChangeEvent::Created);
1624                assert!(diff_summary.contains('+'));
1625                assert_eq!(size_after, Some(1024));
1626            }
1627            _ => panic!("Expected FileModified"),
1628        }
1629    }
1630
1631    #[test]
1632    fn test_file_modified_deleted() {
1633        let msg = HotReloadMessage::file_modified(
1634            "old_file.rs",
1635            FileChangeEvent::Deleted,
1636            Some(2048),
1637            None,
1638        );
1639
1640        match msg {
1641            HotReloadMessage::FileModified {
1642                event,
1643                diff_summary,
1644                size_before,
1645                ..
1646            } => {
1647                assert_eq!(event, FileChangeEvent::Deleted);
1648                assert!(diff_summary.contains('-'));
1649                assert_eq!(size_before, Some(2048));
1650            }
1651            _ => panic!("Expected FileModified"),
1652        }
1653    }
1654
1655    #[test]
1656    fn test_file_modified_size_increase() {
1657        let msg = HotReloadMessage::file_modified(
1658            "lib.rs",
1659            FileChangeEvent::Modified,
1660            Some(1000),
1661            Some(1500),
1662        );
1663
1664        match msg {
1665            HotReloadMessage::FileModified { diff_summary, .. } => {
1666                assert!(diff_summary.contains("+500 bytes"));
1667            }
1668            _ => panic!("Expected FileModified"),
1669        }
1670    }
1671
1672    #[test]
1673    fn test_file_modified_size_decrease() {
1674        let msg = HotReloadMessage::file_modified(
1675            "lib.rs",
1676            FileChangeEvent::Modified,
1677            Some(2000),
1678            Some(1500),
1679        );
1680
1681        match msg {
1682            HotReloadMessage::FileModified { diff_summary, .. } => {
1683                assert!(diff_summary.contains("-500 bytes"));
1684            }
1685            _ => panic!("Expected FileModified"),
1686        }
1687    }
1688
1689    #[test]
1690    fn test_file_modified_json_roundtrip() {
1691        let msg = HotReloadMessage::file_modified(
1692            "test.rs",
1693            FileChangeEvent::Modified,
1694            Some(100),
1695            Some(200),
1696        );
1697        let json = msg.to_json();
1698        assert!(json.contains("FileModified"));
1699        assert!(json.contains("test.rs"));
1700        assert!(json.contains("modified"));
1701
1702        let parsed: HotReloadMessage = serde_json::from_str(&json).expect("parse failed");
1703        match parsed {
1704            HotReloadMessage::FileModified { path, event, .. } => {
1705                assert_eq!(path, "test.rs");
1706                assert_eq!(event, FileChangeEvent::Modified);
1707            }
1708            _ => panic!("Wrong variant after roundtrip"),
1709        }
1710    }
1711
1712    #[test]
1713    fn test_client_connected_message() {
1714        let msg = HotReloadMessage::ClientConnected { client_count: 3 };
1715        let json = msg.to_json();
1716        assert!(json.contains("ClientConnected"));
1717        assert!(json.contains('3'));
1718    }
1719
1720    #[test]
1721    fn test_client_disconnected_message() {
1722        let msg = HotReloadMessage::ClientDisconnected { client_count: 2 };
1723        let json = msg.to_json();
1724        assert!(json.contains("ClientDisconnected"));
1725        assert!(json.contains('2'));
1726    }
1727
1728    // =========================================================================
1729    // Module Validator Tests (PROBAR-SPEC-007)
1730    // =========================================================================
1731
1732    #[test]
1733    fn test_module_validator_extract_es_import() {
1734        // Test basic import from
1735        let line = r"import init from './pkg/app.js';";
1736        assert_eq!(
1737            ModuleValidator::extract_es_import(line),
1738            Some("./pkg/app.js".to_string())
1739        );
1740
1741        // Test double quotes
1742        let line = r#"import { foo } from "/lib/utils.mjs";"#;
1743        assert_eq!(
1744            ModuleValidator::extract_es_import(line),
1745            Some("/lib/utils.mjs".to_string())
1746        );
1747
1748        // Test WASM import
1749        let line = r"import wasm from '../module.wasm';";
1750        assert_eq!(
1751            ModuleValidator::extract_es_import(line),
1752            Some("../module.wasm".to_string())
1753        );
1754
1755        // Test non-JS import (should be None)
1756        let line = r"import styles from './styles.css';";
1757        assert_eq!(ModuleValidator::extract_es_import(line), None);
1758    }
1759
1760    #[test]
1761    fn test_module_validator_extract_script_src() {
1762        let line = r#"<script src="./app.js"></script>"#;
1763        assert_eq!(
1764            ModuleValidator::extract_script_src(line),
1765            Some("./app.js".to_string())
1766        );
1767
1768        let line = r"<script src='/lib/vendor.mjs'></script>";
1769        assert_eq!(
1770            ModuleValidator::extract_script_src(line),
1771            Some("/lib/vendor.mjs".to_string())
1772        );
1773
1774        // Non-JS src
1775        let line = r#"<img src="./image.png">"#;
1776        assert_eq!(ModuleValidator::extract_script_src(line), None);
1777    }
1778
1779    #[test]
1780    fn test_module_validator_extract_worker_url() {
1781        let line = r"const worker = new Worker('./worker.js');";
1782        assert_eq!(
1783            ModuleValidator::extract_worker_url(line),
1784            Some("./worker.js".to_string())
1785        );
1786
1787        let line = r#"new Worker("/pkg/transcription_worker.js")"#;
1788        assert_eq!(
1789            ModuleValidator::extract_worker_url(line),
1790            Some("/pkg/transcription_worker.js".to_string())
1791        );
1792    }
1793
1794    #[test]
1795    fn test_module_validator_resolve_absolute_path() {
1796        let validator = ModuleValidator::new("/srv/www");
1797
1798        let import = ImportRef {
1799            source_file: PathBuf::from("/srv/www/index.html"),
1800            import_path: "/pkg/app.js".to_string(),
1801            import_type: ImportType::EsModule,
1802            line_number: 10,
1803        };
1804
1805        let resolved = validator.resolve_path(&import);
1806        assert_eq!(resolved, Some(PathBuf::from("/srv/www/pkg/app.js")));
1807    }
1808
1809    #[test]
1810    fn test_module_validator_resolve_relative_path() {
1811        let validator = ModuleValidator::new("/srv/www");
1812
1813        let import = ImportRef {
1814            source_file: PathBuf::from("/srv/www/pages/demo.html"),
1815            import_path: "../pkg/app.js".to_string(),
1816            import_type: ImportType::EsModule,
1817            line_number: 5,
1818        };
1819
1820        let resolved = validator.resolve_path(&import);
1821        assert_eq!(
1822            resolved,
1823            Some(PathBuf::from("/srv/www/pages/../pkg/app.js"))
1824        );
1825    }
1826
1827    #[test]
1828    fn test_module_validator_skip_external_urls() {
1829        let validator = ModuleValidator::new("/srv/www");
1830
1831        // Bare specifier (npm package)
1832        let import = ImportRef {
1833            source_file: PathBuf::from("/srv/www/index.html"),
1834            import_path: "lodash".to_string(),
1835            import_type: ImportType::EsModule,
1836            line_number: 1,
1837        };
1838        assert_eq!(validator.resolve_path(&import), None);
1839    }
1840
1841    #[test]
1842    fn test_import_type_expected_mime_types() {
1843        assert!(ImportType::EsModule
1844            .expected_mime_types()
1845            .contains(&"text/javascript"));
1846        assert!(ImportType::Script
1847            .expected_mime_types()
1848            .contains(&"application/javascript"));
1849        assert!(ImportType::Wasm
1850            .expected_mime_types()
1851            .contains(&"application/wasm"));
1852        assert!(ImportType::Worker
1853            .expected_mime_types()
1854            .contains(&"text/javascript"));
1855    }
1856
1857    #[test]
1858    fn test_module_validation_result_is_ok() {
1859        let mut result = ModuleValidationResult::default();
1860        assert!(result.is_ok());
1861
1862        result.errors.push(ImportValidationError {
1863            import: ImportRef {
1864                source_file: PathBuf::from("test.html"),
1865                import_path: "/missing.js".to_string(),
1866                import_type: ImportType::EsModule,
1867                line_number: 1,
1868            },
1869            status: 404,
1870            actual_mime: "text/plain".to_string(),
1871            message: "Not found".to_string(),
1872        });
1873
1874        assert!(!result.is_ok());
1875    }
1876
1877    #[test]
1878    fn test_module_validator_validates_existing_file() {
1879        use tempfile::TempDir;
1880
1881        let temp = TempDir::new().unwrap();
1882        let pkg_dir = temp.path().join("pkg");
1883        std::fs::create_dir(&pkg_dir).unwrap();
1884        std::fs::write(pkg_dir.join("app.js"), "export default {}").unwrap();
1885        std::fs::write(
1886            temp.path().join("index.html"),
1887            r#"<script type="module">import init from './pkg/app.js';</script>"#,
1888        )
1889        .unwrap();
1890
1891        let validator = ModuleValidator::new(temp.path());
1892        let result = validator.validate();
1893
1894        assert_eq!(result.total_imports, 1);
1895        assert_eq!(result.passed, 1);
1896        assert!(result.errors.is_empty());
1897    }
1898
1899    #[test]
1900    fn test_module_validator_detects_missing_file() {
1901        use tempfile::TempDir;
1902
1903        let temp = TempDir::new().unwrap();
1904        std::fs::write(
1905            temp.path().join("index.html"),
1906            r#"<script type="module">import init from './pkg/missing.js';</script>"#,
1907        )
1908        .unwrap();
1909
1910        let validator = ModuleValidator::new(temp.path());
1911        let result = validator.validate();
1912
1913        assert_eq!(result.total_imports, 1);
1914        assert_eq!(result.passed, 0);
1915        assert_eq!(result.errors.len(), 1);
1916        assert_eq!(result.errors[0].status, 404);
1917    }
1918
1919    // =========================================================================
1920    // format_bytes Tests
1921    // =========================================================================
1922
1923    #[test]
1924    fn test_format_bytes_bytes() {
1925        assert_eq!(format_bytes(0), "0 bytes");
1926        assert_eq!(format_bytes(512), "512 bytes");
1927        assert_eq!(format_bytes(1023), "1023 bytes");
1928    }
1929
1930    #[test]
1931    fn test_format_bytes_kilobytes() {
1932        assert_eq!(format_bytes(1024), "1.0 KB");
1933        assert_eq!(format_bytes(2048), "2.0 KB");
1934        assert_eq!(format_bytes(1536), "1.5 KB");
1935    }
1936
1937    #[test]
1938    fn test_format_bytes_megabytes() {
1939        assert_eq!(format_bytes(1048576), "1.0 MB");
1940        assert_eq!(format_bytes(5242880), "5.0 MB");
1941    }
1942
1943    #[test]
1944    fn test_file_modified_renamed_with_sizes() {
1945        let msg = HotReloadMessage::file_modified(
1946            "src/renamed.rs",
1947            FileChangeEvent::Renamed,
1948            Some(100),
1949            Some(100),
1950        );
1951        if let HotReloadMessage::FileModified { diff_summary, .. } = msg {
1952            assert_eq!(diff_summary, "renamed");
1953        } else {
1954            panic!("Expected FileModified variant");
1955        }
1956    }
1957
1958    #[test]
1959    fn test_file_modified_fallback_changed() {
1960        // Test the fallback case for non-matching pattern
1961        let msg = HotReloadMessage::file_modified(
1962            "src/test.rs",
1963            FileChangeEvent::Modified,
1964            None, // No size before
1965            None, // No size after
1966        );
1967        if let HotReloadMessage::FileModified { diff_summary, .. } = msg {
1968            assert_eq!(diff_summary, "changed");
1969        } else {
1970            panic!("Expected FileModified variant");
1971        }
1972    }
1973}