1#![allow(clippy::must_use_candidate)]
14#![allow(clippy::missing_panics_doc)]
15
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, Ordering};
18use std::sync::Arc;
19use std::time::Instant;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum DebugVerbosity {
24 Minimal,
26 Normal,
28 #[default]
30 Verbose,
31 Trace,
33}
34
35#[derive(Debug, Clone, Copy)]
37pub enum DebugCategory {
38 Server,
40 Request,
42 Resolve,
44 Response,
46 Error,
48 WebSocket,
50 Watcher,
52}
53
54impl DebugCategory {
55 #[must_use]
57 pub const fn as_str(&self) -> &'static str {
58 match self {
59 Self::Server => "SERVER",
60 Self::Request => "REQUEST",
61 Self::Resolve => "RESOLVE",
62 Self::Response => "RESPONSE",
63 Self::Error => "ERROR",
64 Self::WebSocket => "WS",
65 Self::Watcher => "WATCHER",
66 }
67 }
68
69 #[must_use]
71 pub const fn color(&self) -> &'static str {
72 match self {
73 Self::Server => "\x1b[36m", Self::Request => "\x1b[34m", Self::Resolve => "\x1b[35m", Self::Response => "\x1b[32m", Self::Error => "\x1b[31m", Self::WebSocket => "\x1b[33m", Self::Watcher => "\x1b[90m", }
81 }
82}
83
84#[derive(Debug, Clone, Copy)]
86pub enum ResolutionRule {
87 DirectoryIndex,
89 StaticFile,
91 Fallback,
93 NotFound,
95}
96
97impl ResolutionRule {
98 #[must_use]
100 pub const fn as_str(&self) -> &'static str {
101 match self {
102 Self::DirectoryIndex => "Directory index (index.html)",
103 Self::StaticFile => "Static file",
104 Self::Fallback => "Fallback",
105 Self::NotFound => "Not found",
106 }
107 }
108}
109
110#[derive(Debug)]
112pub struct DebugTracer {
113 enabled: bool,
115 verbosity: DebugVerbosity,
117 start_time: Instant,
119 request_count: AtomicU64,
121 use_colors: bool,
123}
124
125impl Default for DebugTracer {
126 fn default() -> Self {
127 Self::new(false)
128 }
129}
130
131impl DebugTracer {
132 #[must_use]
134 pub fn new(enabled: bool) -> Self {
135 Self {
136 enabled,
137 verbosity: DebugVerbosity::Verbose,
138 start_time: Instant::now(),
139 request_count: AtomicU64::new(0),
140 use_colors: atty::is(atty::Stream::Stdout),
141 }
142 }
143
144 #[must_use]
146 pub fn enabled() -> Self {
147 Self::new(true)
148 }
149
150 #[must_use]
152 pub const fn with_verbosity(mut self, verbosity: DebugVerbosity) -> Self {
153 self.verbosity = verbosity;
154 self
155 }
156
157 #[must_use]
159 pub const fn is_enabled(&self) -> bool {
160 self.enabled
161 }
162
163 fn elapsed_str(&self) -> String {
165 let elapsed = self.start_time.elapsed();
166 let secs = elapsed.as_secs();
167 let millis = elapsed.subsec_millis();
168 format!("{secs:02}:{millis:03}")
169 }
170
171 fn format_line(&self, category: DebugCategory, message: &str) -> String {
173 let timestamp = self.elapsed_str();
174 let cat_str = category.as_str();
175
176 if self.use_colors {
177 let color = category.color();
178 let reset = "\x1b[0m";
179 format!("[{timestamp}] {color}{cat_str:8}{reset} │ {message}")
180 } else {
181 format!("[{timestamp}] {cat_str:8} │ {message}")
182 }
183 }
184
185 pub fn log(&self, category: DebugCategory, message: &str) {
187 if !self.enabled {
188 return;
189 }
190 println!("{}", self.format_line(category, message));
191 }
192
193 pub fn log_multi(&self, category: DebugCategory, lines: &[&str]) {
195 if !self.enabled || lines.is_empty() {
196 return;
197 }
198
199 println!("{}", self.format_line(category, lines[0]));
201
202 let padding = " │ ";
204
205 for line in &lines[1..] {
206 println!("{padding}{line}");
207 }
208 }
209
210 pub fn log_server_start(&self, port: u16, directory: &Path, cors: bool, coop_coep: bool) {
212 if !self.enabled {
213 return;
214 }
215
216 println!();
217 self.log(DebugCategory::Server, "DEBUG MODE ACTIVE");
218 println!("━━━━━━━━━━━━━━━━━");
219 println!();
220
221 self.log(
222 DebugCategory::Server,
223 &format!("Binding to 127.0.0.1:{port}"),
224 );
225 self.log(DebugCategory::Server, "Registered routes:");
226 self.log(
227 DebugCategory::Server,
228 &format!(" GET / -> {}/index.html", directory.display()),
229 );
230 self.log(
231 DebugCategory::Server,
232 &format!(" GET /* -> {} (static)", directory.display()),
233 );
234 self.log(DebugCategory::Server, " GET /ws -> WebSocket");
235
236 self.log(
237 DebugCategory::Server,
238 &format!(
239 "CORS headers: {}",
240 if cors {
241 "enabled (Access-Control-Allow-Origin: *)"
242 } else {
243 "disabled"
244 }
245 ),
246 );
247
248 self.log(
249 DebugCategory::Server,
250 &format!(
251 "COOP/COEP headers: {}",
252 if coop_coep {
253 "enabled (SharedArrayBuffer available)"
254 } else {
255 "disabled"
256 }
257 ),
258 );
259
260 println!();
261 }
262
263 pub fn log_request(
265 &self,
266 method: &str,
267 path: &str,
268 client_addr: Option<&str>,
269 user_agent: Option<&str>,
270 ) {
271 if !self.enabled {
272 return;
273 }
274
275 let req_num = self.request_count.fetch_add(1, Ordering::SeqCst) + 1;
276
277 let mut lines = vec![format!("#{req_num} {method} {path}")];
278
279 if let Some(addr) = client_addr {
280 lines.push(format!("Client: {addr}"));
281 }
282
283 if let Some(ua) = user_agent {
284 let ua_short = if ua.len() > 50 {
286 format!("{}...", &ua[..47])
287 } else {
288 ua.to_string()
289 };
290 lines.push(format!("User-Agent: {ua_short}"));
291 }
292
293 let line_refs: Vec<&str> = lines.iter().map(String::as_str).collect();
294 self.log_multi(DebugCategory::Request, &line_refs);
295 }
296
297 pub fn log_resolve(&self, request_path: &str, resolved_path: &Path, rule: ResolutionRule) {
299 if !self.enabled {
300 return;
301 }
302
303 self.log_multi(
304 DebugCategory::Resolve,
305 &[
306 &format!("Path: {request_path}"),
307 &format!("Resolved: {}", resolved_path.display()),
308 &format!("Rule: {}", rule.as_str()),
309 ],
310 );
311 }
312
313 pub fn log_response(
315 &self,
316 status: u16,
317 content_type: &str,
318 content_length: usize,
319 latency_ms: u64,
320 ) {
321 if !self.enabled {
322 return;
323 }
324
325 let status_str = match status {
326 200 => "200 OK",
327 304 => "304 Not Modified",
328 404 => "404 Not Found",
329 500 => "500 Internal Server Error",
330 _ => "Unknown",
331 };
332
333 self.log_multi(
334 DebugCategory::Response,
335 &[
336 &format!("Status: {status_str}"),
337 &format!("Content-Type: {content_type}"),
338 &format!("Content-Length: {content_length}"),
339 &format!("Latency: {latency_ms}ms"),
340 ],
341 );
342 }
343
344 pub fn log_not_found(
346 &self,
347 request_path: &str,
348 searched_paths: &[PathBuf],
349 suggestions: &[String],
350 ) {
351 if !self.enabled {
352 return;
353 }
354
355 let mut lines = vec![
356 format!("GET {request_path}"),
357 "Error: File not found".to_string(),
358 ];
359
360 lines.push("Searched paths:".to_string());
361 for (i, path) in searched_paths.iter().enumerate() {
362 lines.push(format!(" {}. {}", i + 1, path.display()));
363 }
364
365 if !suggestions.is_empty() {
366 lines.push("Suggestions:".to_string());
367 for suggestion in suggestions {
368 lines.push(format!(" - {suggestion}"));
369 }
370 }
371
372 let line_refs: Vec<&str> = lines.iter().map(String::as_str).collect();
373 self.log_multi(DebugCategory::Error, &line_refs);
374 }
375
376 pub fn log_mime_check(&self, path: &Path, mime_type: &str, is_correct: bool) {
378 if !self.enabled {
379 return;
380 }
381
382 let status = if is_correct {
383 "✓ CORRECT"
384 } else {
385 "✗ INCORRECT"
386 };
387
388 self.log(
389 DebugCategory::Response,
390 &format!(
391 "Content-Type: {} {} ({})",
392 mime_type,
393 status,
394 path.display()
395 ),
396 );
397 }
398
399 pub fn log_ws_connect(&self, client_addr: &str) {
401 if !self.enabled {
402 return;
403 }
404 self.log(
405 DebugCategory::WebSocket,
406 &format!("Client connected: {client_addr}"),
407 );
408 }
409
410 pub fn log_ws_disconnect(&self, client_addr: &str) {
412 if !self.enabled {
413 return;
414 }
415 self.log(
416 DebugCategory::WebSocket,
417 &format!("Client disconnected: {client_addr}"),
418 );
419 }
420
421 pub fn log_file_change(&self, path: &str, event_type: &str) {
423 if !self.enabled {
424 return;
425 }
426 self.log(DebugCategory::Watcher, &format!("{event_type}: {path}"));
427 }
428}
429
430#[must_use]
432pub fn create_tracer(enabled: bool) -> Arc<DebugTracer> {
433 Arc::new(DebugTracer::new(enabled))
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use std::path::PathBuf;
440
441 #[test]
442 fn test_debug_tracer_creation() {
443 let tracer = DebugTracer::new(false);
444 assert!(!tracer.is_enabled());
445
446 let tracer = DebugTracer::enabled();
447 assert!(tracer.is_enabled());
448 }
449
450 #[test]
451 fn test_debug_verbosity_default() {
452 let verbosity = DebugVerbosity::default();
453 assert_eq!(verbosity, DebugVerbosity::Verbose);
454 }
455
456 #[test]
457 fn test_debug_category_str() {
458 assert_eq!(DebugCategory::Server.as_str(), "SERVER");
459 assert_eq!(DebugCategory::Request.as_str(), "REQUEST");
460 assert_eq!(DebugCategory::Error.as_str(), "ERROR");
461 }
462
463 #[test]
464 fn test_resolution_rule_str() {
465 assert_eq!(
466 ResolutionRule::DirectoryIndex.as_str(),
467 "Directory index (index.html)"
468 );
469 assert_eq!(ResolutionRule::StaticFile.as_str(), "Static file");
470 }
471
472 #[test]
473 fn test_tracer_disabled_no_output() {
474 let tracer = DebugTracer::new(false);
475 tracer.log(DebugCategory::Server, "test");
477 tracer.log_multi(DebugCategory::Request, &["line1", "line2"]);
478 }
479
480 #[test]
481 fn test_tracer_with_verbosity() {
482 let tracer = DebugTracer::new(true).with_verbosity(DebugVerbosity::Minimal);
483 assert!(tracer.is_enabled());
484 }
485
486 #[test]
487 fn test_create_tracer() {
488 let tracer = create_tracer(true);
489 assert!(tracer.is_enabled());
490 }
491
492 #[test]
493 fn test_format_line() {
494 let tracer = DebugTracer::new(true);
495 let line = tracer.format_line(DebugCategory::Server, "test message");
496 assert!(line.contains("SERVER"));
497 assert!(line.contains("test message"));
498 }
499
500 #[test]
505 fn test_debug_category_all_variants_str() {
506 assert_eq!(DebugCategory::Server.as_str(), "SERVER");
508 assert_eq!(DebugCategory::Request.as_str(), "REQUEST");
509 assert_eq!(DebugCategory::Resolve.as_str(), "RESOLVE");
510 assert_eq!(DebugCategory::Response.as_str(), "RESPONSE");
511 assert_eq!(DebugCategory::Error.as_str(), "ERROR");
512 assert_eq!(DebugCategory::WebSocket.as_str(), "WS");
513 assert_eq!(DebugCategory::Watcher.as_str(), "WATCHER");
514 }
515
516 #[test]
517 fn test_debug_category_all_variants_color() {
518 assert!(DebugCategory::Server.color().contains("\x1b["));
520 assert!(DebugCategory::Request.color().contains("\x1b["));
521 assert!(DebugCategory::Resolve.color().contains("\x1b["));
522 assert!(DebugCategory::Response.color().contains("\x1b["));
523 assert!(DebugCategory::Error.color().contains("\x1b["));
524 assert!(DebugCategory::WebSocket.color().contains("\x1b["));
525 assert!(DebugCategory::Watcher.color().contains("\x1b["));
526 }
527
528 #[test]
529 fn test_resolution_rule_all_variants_str() {
530 assert_eq!(
532 ResolutionRule::DirectoryIndex.as_str(),
533 "Directory index (index.html)"
534 );
535 assert_eq!(ResolutionRule::StaticFile.as_str(), "Static file");
536 assert_eq!(ResolutionRule::Fallback.as_str(), "Fallback");
537 assert_eq!(ResolutionRule::NotFound.as_str(), "Not found");
538 }
539
540 #[test]
541 fn test_debug_verbosity_all_variants() {
542 assert_ne!(DebugVerbosity::Minimal, DebugVerbosity::Normal);
544 assert_ne!(DebugVerbosity::Normal, DebugVerbosity::Verbose);
545 assert_ne!(DebugVerbosity::Verbose, DebugVerbosity::Trace);
546 }
547
548 #[test]
549 fn test_tracer_default() {
550 let tracer = DebugTracer::default();
551 assert!(!tracer.is_enabled());
552 }
553
554 #[test]
555 fn test_create_tracer_disabled() {
556 let tracer = create_tracer(false);
557 assert!(!tracer.is_enabled());
558 }
559
560 #[test]
561 fn test_log_multi_empty() {
562 let tracer = DebugTracer::new(false);
563 tracer.log_multi(DebugCategory::Server, &[]);
565 }
566
567 #[test]
568 fn test_all_log_methods_disabled() {
569 let tracer = DebugTracer::new(false);
571 let path = PathBuf::from("/test/path");
572 let searched: Vec<PathBuf> = vec![PathBuf::from("/search1"), PathBuf::from("/search2")];
573 let suggestions: Vec<String> = vec!["suggestion1".to_string()];
574
575 tracer.log(DebugCategory::Server, "test");
576 tracer.log_multi(DebugCategory::Request, &["line1", "line2"]);
577 tracer.log_server_start(8080, &path, true, true);
578 tracer.log_request("GET", "/", Some("127.0.0.1"), Some("Mozilla/5.0"));
579 tracer.log_resolve("/", &path, ResolutionRule::DirectoryIndex);
580 tracer.log_response(200, "text/html", 1024, 50);
581 tracer.log_not_found("/missing.txt", &searched, &suggestions);
582 tracer.log_mime_check(&path, "text/html", true);
583 tracer.log_ws_connect("127.0.0.1");
584 tracer.log_ws_disconnect("127.0.0.1");
585 tracer.log_file_change("/test/file.rs", "modified");
586 }
587
588 #[test]
589 fn test_format_line_no_colors() {
590 let mut tracer = DebugTracer::new(true);
592 tracer.use_colors = false;
593
594 let line = tracer.format_line(DebugCategory::Server, "test");
595 assert!(line.contains("SERVER"));
596 assert!(!line.contains("\x1b[")); }
598
599 #[test]
600 fn test_format_line_with_colors() {
601 let mut tracer = DebugTracer::new(true);
603 tracer.use_colors = true;
604
605 let line = tracer.format_line(DebugCategory::Server, "test");
606 assert!(line.contains("SERVER"));
607 assert!(line.contains("\x1b[")); }
609
610 #[test]
611 fn test_elapsed_str_format() {
612 let tracer = DebugTracer::new(true);
613 let elapsed = tracer.elapsed_str();
614 assert!(elapsed.contains(':'));
616 assert!(elapsed.len() >= 5); }
618
619 #[test]
620 fn test_debug_category_debug_impl() {
621 let cat = DebugCategory::Server;
623 let debug_str = format!("{cat:?}");
624 assert!(debug_str.contains("Server"));
625 }
626
627 #[test]
628 fn test_debug_verbosity_clone() {
629 let verbosity = DebugVerbosity::Verbose;
630 let cloned = verbosity;
631 assert_eq!(verbosity, cloned);
632 }
633
634 #[test]
635 fn test_resolution_rule_debug_impl() {
636 let rule = ResolutionRule::StaticFile;
637 let debug_str = format!("{rule:?}");
638 assert!(debug_str.contains("StaticFile"));
639 }
640
641 #[test]
642 fn test_all_log_methods_enabled() {
643 let tracer = DebugTracer::enabled();
645 let path = PathBuf::from("/test/path");
646 let searched: Vec<PathBuf> = vec![PathBuf::from("/search1")];
647 let suggestions: Vec<String> = vec!["Try checking the path".to_string()];
648
649 tracer.log(DebugCategory::Server, "Server message");
651 tracer.log(DebugCategory::Request, "Request message");
652 tracer.log(DebugCategory::Resolve, "Resolve message");
653 tracer.log(DebugCategory::Response, "Response message");
654 tracer.log(DebugCategory::Error, "Error message");
655 tracer.log(DebugCategory::WebSocket, "WebSocket message");
656 tracer.log(DebugCategory::Watcher, "Watcher message");
657
658 tracer.log_multi(DebugCategory::Server, &["line1", "line2", "line3"]);
659 tracer.log_server_start(3000, &path, true, false);
660 tracer.log_server_start(3001, &path, false, true);
661 tracer.log_request("POST", "/api/test", Some("192.168.1.1"), None);
662 tracer.log_request("GET", "/", None, Some("curl/7.0"));
663 tracer.log_resolve("/index.html", &path, ResolutionRule::StaticFile);
664 tracer.log_resolve("/", &path, ResolutionRule::Fallback);
665 tracer.log_resolve("/missing", &path, ResolutionRule::NotFound);
666 tracer.log_response(404, "text/plain", 0, 1);
667 tracer.log_response(500, "application/json", 100, 5);
668 tracer.log_not_found("/missing.css", &searched, &suggestions);
669 tracer.log_not_found("/missing.js", &[], &[]);
670 tracer.log_mime_check(&path, "application/wasm", false);
671 tracer.log_ws_connect("10.0.0.1");
672 tracer.log_ws_disconnect("10.0.0.1");
673 tracer.log_file_change("/src/main.rs", "created");
674 tracer.log_file_change("/src/lib.rs", "deleted");
675 }
676
677 #[test]
678 fn test_tracer_with_all_verbosity_levels() {
679 let tracer_minimal = DebugTracer::enabled().with_verbosity(DebugVerbosity::Minimal);
680 let tracer_normal = DebugTracer::enabled().with_verbosity(DebugVerbosity::Normal);
681 let tracer_verbose = DebugTracer::enabled().with_verbosity(DebugVerbosity::Verbose);
682 let tracer_trace = DebugTracer::enabled().with_verbosity(DebugVerbosity::Trace);
683
684 assert!(tracer_minimal.is_enabled());
686 assert!(tracer_normal.is_enabled());
687 assert!(tracer_verbose.is_enabled());
688 assert!(tracer_trace.is_enabled());
689 }
690
691 #[test]
692 fn test_debug_category_copy_semantics() {
693 let cat = DebugCategory::Error;
694 let copied = cat;
695 assert_eq!(cat.as_str(), copied.as_str());
696 assert_eq!(cat.color(), copied.color());
697 }
698
699 #[test]
700 fn test_resolution_rule_copy_semantics() {
701 let rule = ResolutionRule::Fallback;
702 let copied = rule;
703 assert_eq!(rule.as_str(), copied.as_str());
704 }
705
706 #[test]
707 fn test_debug_verbosity_eq_all_pairs() {
708 let variants = [
709 DebugVerbosity::Minimal,
710 DebugVerbosity::Normal,
711 DebugVerbosity::Verbose,
712 DebugVerbosity::Trace,
713 ];
714 for (i, a) in variants.iter().enumerate() {
715 for (j, b) in variants.iter().enumerate() {
716 if i == j {
717 assert_eq!(a, b);
718 } else {
719 assert_ne!(a, b);
720 }
721 }
722 }
723 }
724}