1#![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#[derive(Clone, Debug, Serialize, Deserialize)]
60#[serde(tag = "type", content = "data")]
61pub enum HotReloadMessage {
62 FileChanged {
64 path: String,
66 },
67 RebuildStarted,
69 RebuildComplete {
71 duration_ms: u64,
73 },
74 RebuildFailed {
76 error: String,
78 },
79 ServerReady,
81 FileModified {
83 path: String,
85 event: FileChangeEvent,
87 timestamp: u64,
89 size_before: Option<u64>,
91 size_after: Option<u64>,
93 diff_summary: String,
95 },
96 ClientConnected {
98 client_count: usize,
100 },
101 ClientDisconnected {
103 client_count: usize,
105 },
106}
107
108#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
110#[serde(rename_all = "lowercase")]
111pub enum FileChangeEvent {
112 Created,
114 Modified,
116 Deleted,
118 Renamed,
120}
121
122impl HotReloadMessage {
123 #[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 #[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 #[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
182fn 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#[derive(Debug, Clone)]
198pub struct DevServerConfig {
199 pub directory: PathBuf,
201 pub port: u16,
203 pub ws_port: u16,
205 pub cors: bool,
207 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 #[must_use]
226 pub fn builder() -> DevServerConfigBuilder {
227 DevServerConfigBuilder::default()
228 }
229}
230
231#[derive(Debug, Clone, Default)]
233pub struct DevServerConfigBuilder {
234 config: DevServerConfig,
235}
236
237impl DevServerConfigBuilder {
238 #[must_use]
240 pub fn directory(mut self, dir: impl Into<PathBuf>) -> Self {
241 self.config.directory = dir.into();
242 self
243 }
244
245 #[must_use]
247 pub fn port(mut self, port: u16) -> Self {
248 self.config.port = port;
249 self
250 }
251
252 #[must_use]
254 pub fn ws_port(mut self, port: u16) -> Self {
255 self.config.ws_port = port;
256 self
257 }
258
259 #[must_use]
261 pub fn cors(mut self, enabled: bool) -> Self {
262 self.config.cors = enabled;
263 self
264 }
265
266 #[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 #[must_use]
280 pub fn build(self) -> DevServerConfig {
281 self.config
282 }
283}
284
285#[derive(Debug)]
287pub struct DevServer {
288 config: DevServerConfig,
289 reload_tx: broadcast::Sender<HotReloadMessage>,
290}
291
292impl DevServer {
293 #[must_use]
295 pub fn new(config: DevServerConfig) -> Self {
296 let (reload_tx, _) = broadcast::channel(64);
297 Self { config, reload_tx }
298 }
299
300 #[must_use]
302 pub fn reload_sender(&self) -> broadcast::Sender<HotReloadMessage> {
303 self.reload_tx.clone()
304 }
305
306 #[must_use]
308 pub fn http_url(&self) -> String {
309 format!("http://localhost:{}", self.config.port)
310 }
311
312 #[must_use]
314 pub fn ws_url(&self) -> String {
315 format!("ws://localhost:{}/ws", self.config.port)
316 }
317
318 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 let app = Router::new()
328 .route(
330 "/ws",
331 get({
332 let tx = reload_tx.clone();
333 move |ws: WebSocketUpgrade| handle_websocket(ws, tx.clone())
334 }),
335 )
336 .route(
338 "/",
339 get({
340 let dir = directory.clone();
341 move || serve_index(dir.clone())
342 }),
343 )
344 .fallback({
346 let dir = directory.clone();
347 move |uri: axum::http::Uri| serve_static(dir.clone(), uri)
348 });
349
350 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 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 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 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 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 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 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
507async 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
515async 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 let ready_msg = HotReloadMessage::ServerReady.to_json();
522 if sender.send(Message::Text(ready_msg.into())).await.is_err() {
523 return;
524 }
525
526 loop {
528 tokio::select! {
529 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 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
557async fn serve_index(directory: Arc<PathBuf>) -> Response {
559 let index_path = directory.join("index.html");
560 serve_file(&index_path).await
561}
562
563async fn serve_static(directory: Arc<PathBuf>, uri: axum::http::Uri) -> Response {
568 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 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 (
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
593async fn serve_file(path: &std::path::Path) -> Response {
599 match tokio::fs::read(path).await {
600 Ok(contents) => {
601 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#[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
645pub 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#[derive(Debug)]
730pub struct FileWatcher {
731 pub path: PathBuf,
733 pub debounce_ms: u64,
735 pub patterns: Vec<String>,
737}
738
739impl FileWatcher {
740 #[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 #[must_use]
752 pub fn builder() -> FileWatcherBuilder {
753 FileWatcherBuilder::default()
754 }
755
756 #[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 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 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#[derive(Debug, Clone, Default)]
817pub struct FileWatcherBuilder {
818 path: Option<PathBuf>,
819 debounce_ms: u64,
820 patterns: Vec<String>,
821}
822
823impl FileWatcherBuilder {
824 #[must_use]
826 pub fn path(mut self, path: impl Into<PathBuf>) -> Self {
827 self.path = Some(path.into());
828 self
829 }
830
831 #[must_use]
833 pub fn debounce_ms(mut self, ms: u64) -> Self {
834 self.debounce_ms = ms;
835 self
836 }
837
838 #[must_use]
840 pub fn pattern(mut self, ext: impl Into<String>) -> Self {
841 self.patterns.push(ext.into());
842 self
843 }
844
845 #[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#[derive(Debug, Clone)]
870pub struct ImportRef {
871 pub source_file: PathBuf,
873 pub import_path: String,
875 pub import_type: ImportType,
877 pub line_number: u32,
879}
880
881#[derive(Debug, Clone, Copy, PartialEq, Eq)]
883pub enum ImportType {
884 EsModule,
886 Script,
888 Wasm,
890 Worker,
892}
893
894impl ImportType {
895 #[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#[derive(Debug, Clone)]
909pub struct ImportValidationError {
910 pub import: ImportRef,
912 pub status: u16,
914 pub actual_mime: String,
916 pub message: String,
918}
919
920#[derive(Debug, Default)]
922pub struct ModuleValidationResult {
923 pub total_imports: usize,
925 pub passed: usize,
927 pub errors: Vec<ImportValidationError>,
929}
930
931impl ModuleValidationResult {
932 #[must_use]
934 pub fn is_ok(&self) -> bool {
935 self.errors.is_empty()
936 }
937}
938
939#[derive(Debug)]
941pub struct ModuleValidator {
942 serve_root: PathBuf,
944 exclude: Vec<String>,
946}
947
948impl ModuleValidator {
949 #[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()], }
956 }
957
958 #[must_use]
960 pub fn with_exclude(mut self, exclude: Vec<String>) -> Self {
961 self.exclude = exclude;
962 self
963 }
964
965 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 #[must_use]
975 pub fn scan_imports(&self) -> Vec<ImportRef> {
976 let mut imports = Vec::new();
977
978 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 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 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 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 if let Some(path) = Self::extract_script_src(line) {
1014 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 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 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 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 fn extract_es_import(line: &str) -> Option<String> {
1063 let patterns = [
1065 (r"from '", "'"),
1066 (r#"from ""#, "\""),
1067 (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 if Self::has_js_or_wasm_extension(path) {
1079 return Some(path.to_string());
1080 }
1081 }
1082 }
1083 }
1084
1085 None
1086 }
1087
1088 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 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 fn resolve_path(&self, import: &ImportRef) -> Option<PathBuf> {
1126 let import_path = &import.import_path;
1127
1128 if import_path.starts_with('/') {
1129 Some(self.serve_root.join(import_path.trim_start_matches('/')))
1131 } else if import_path.starts_with("./") || import_path.starts_with("../") {
1132 let source_dir = import.source_file.parent()?;
1134 Some(source_dir.join(import_path))
1135 } else {
1136 None
1138 }
1139 }
1140
1141 #[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 let canonical = resolved.canonicalize();
1154
1155 match canonical {
1156 Ok(path) if path.exists() => {
1157 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 result.passed += 1;
1190 }
1191 }
1192
1193 result
1194 }
1195
1196 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#[cfg(test)]
1224#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1225mod tests {
1226 use super::*;
1227
1228 #[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 #[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 let result = tx.send(HotReloadMessage::ServerReady);
1291 assert!(result.is_ok() || result.is_err());
1293 }
1294
1295 #[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 #[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 #[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 let mime = get_mime_type(&PathBuf::from("data.xyz"));
1461 assert!(!mime.is_empty());
1462 }
1463
1464 #[tokio::test]
1469 async fn test_serve_static_directory_serves_index_html() {
1470 use std::sync::Arc;
1471 use tempfile::TempDir;
1472
1473 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 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 assert!(response.status().is_client_error() || response.status().is_server_error());
1519 }
1520
1521 #[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 #[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 let _: HotReloadMessage = serde_json::from_str(&json).expect("parse failed");
1590 }
1591 }
1592
1593 #[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 #[test]
1733 fn test_module_validator_extract_es_import() {
1734 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 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 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 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 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 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 #[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 let msg = HotReloadMessage::file_modified(
1962 "src/test.rs",
1963 FileChangeEvent::Modified,
1964 None, None, );
1967 if let HotReloadMessage::FileModified { diff_summary, .. } = msg {
1968 assert_eq!(diff_summary, "changed");
1969 } else {
1970 panic!("Expected FileModified variant");
1971 }
1972 }
1973}