Skip to main content

probador/
debug.rs

1//! Debug Mode Implementation
2//!
3//! Provides verbose request/response logging and step-by-step playback
4//! for debugging WASM applications.
5//!
6//! ## Features
7//!
8//! - Request/response tracing with full headers
9//! - File resolution debugging (shows which rules matched)
10//! - CORS and COOP/COEP header visibility
11//! - Suggestions for common issues (404s, MIME types)
12
13#![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/// Debug verbosity levels
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum DebugVerbosity {
24    /// Errors only
25    Minimal,
26    /// Errors + warnings
27    Normal,
28    /// All requests/responses
29    #[default]
30    Verbose,
31    /// Everything including internal state
32    Trace,
33}
34
35/// Debug event category
36#[derive(Debug, Clone, Copy)]
37pub enum DebugCategory {
38    /// Server lifecycle events
39    Server,
40    /// Incoming requests
41    Request,
42    /// File resolution
43    Resolve,
44    /// Outgoing responses
45    Response,
46    /// Errors
47    Error,
48    /// WebSocket events
49    WebSocket,
50    /// File watcher events
51    Watcher,
52}
53
54impl DebugCategory {
55    /// Get the display string for this category
56    #[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    /// Get ANSI color code for this category
70    #[must_use]
71    pub const fn color(&self) -> &'static str {
72        match self {
73            Self::Server => "\x1b[36m",    // Cyan
74            Self::Request => "\x1b[34m",   // Blue
75            Self::Resolve => "\x1b[35m",   // Magenta
76            Self::Response => "\x1b[32m",  // Green
77            Self::Error => "\x1b[31m",     // Red
78            Self::WebSocket => "\x1b[33m", // Yellow
79            Self::Watcher => "\x1b[90m",   // Gray
80        }
81    }
82}
83
84/// Resolution rule that matched a request
85#[derive(Debug, Clone, Copy)]
86pub enum ResolutionRule {
87    /// Served index.html for directory
88    DirectoryIndex,
89    /// Served static file directly
90    StaticFile,
91    /// Fallback to default
92    Fallback,
93    /// File not found
94    NotFound,
95}
96
97impl ResolutionRule {
98    /// Get display string
99    #[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/// Debug tracer for the development server
111#[derive(Debug)]
112pub struct DebugTracer {
113    /// Whether debug mode is enabled
114    enabled: bool,
115    /// Verbosity level
116    verbosity: DebugVerbosity,
117    /// Start time for relative timestamps
118    start_time: Instant,
119    /// Request counter
120    request_count: AtomicU64,
121    /// Whether to use colors
122    use_colors: bool,
123}
124
125impl Default for DebugTracer {
126    fn default() -> Self {
127        Self::new(false)
128    }
129}
130
131impl DebugTracer {
132    /// Create a new debug tracer
133    #[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    /// Create enabled tracer
145    #[must_use]
146    pub fn enabled() -> Self {
147        Self::new(true)
148    }
149
150    /// Set verbosity level
151    #[must_use]
152    pub const fn with_verbosity(mut self, verbosity: DebugVerbosity) -> Self {
153        self.verbosity = verbosity;
154        self
155    }
156
157    /// Check if debug mode is enabled
158    #[must_use]
159    pub const fn is_enabled(&self) -> bool {
160        self.enabled
161    }
162
163    /// Get elapsed time since start
164    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    /// Format a log line
172    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    /// Log a debug event
186    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    /// Log a multi-line debug event
194    pub fn log_multi(&self, category: DebugCategory, lines: &[&str]) {
195        if !self.enabled || lines.is_empty() {
196            return;
197        }
198
199        // First line with category
200        println!("{}", self.format_line(category, lines[0]));
201
202        // Continuation lines with padding
203        let padding = "                      │ ";
204
205        for line in &lines[1..] {
206            println!("{padding}{line}");
207        }
208    }
209
210    /// Log server startup
211    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    /// Log an incoming request
264    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            // Truncate long user agents
285            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    /// Log file resolution
298    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    /// Log a response
314    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    /// Log a 404 error with suggestions
345    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    /// Log MIME type information (especially for WASM)
377    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    /// Log WebSocket connection
400    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    /// Log WebSocket disconnection
411    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    /// Log file change event
422    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/// Create a shared debug tracer
431#[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        // Should not panic even when disabled
476        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    // =========================================================================
501    // Additional coverage tests for debug.rs
502    // =========================================================================
503
504    #[test]
505    fn test_debug_category_all_variants_str() {
506        // Test all category string representations
507        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        // Test all category color codes
519        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        // Test all resolution rule string representations
531        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        // Ensure all variants are distinct
543        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        // Empty lines should not panic
564        tracer.log_multi(DebugCategory::Server, &[]);
565    }
566
567    #[test]
568    fn test_all_log_methods_disabled() {
569        // All log methods should not panic when tracer is disabled
570        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        // Force no colors by creating a tracer with use_colors = false
591        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[")); // No ANSI codes
597    }
598
599    #[test]
600    fn test_format_line_with_colors() {
601        // Force colors
602        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[")); // Has ANSI codes
608    }
609
610    #[test]
611    fn test_elapsed_str_format() {
612        let tracer = DebugTracer::new(true);
613        let elapsed = tracer.elapsed_str();
614        // Should be in format "SS:MMM"
615        assert!(elapsed.contains(':'));
616        assert!(elapsed.len() >= 5); // At least "00:000"
617    }
618
619    #[test]
620    fn test_debug_category_debug_impl() {
621        // Test Debug trait implementation
622        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        // Test all log methods when tracer IS enabled (exercises the printing code)
644        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        // These should all execute without panicking
650        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        // All should be enabled
685        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}