1use axum::http::{HeaderMap, StatusCode};
21use axum::response::{IntoResponse, Response};
22use once_cell::sync::Lazy;
23use parking_lot::Mutex;
24use std::collections::HashMap;
25
26#[derive(Debug, Clone)]
34pub struct StackFrame {
35 pub file: String,
37 pub line: usize,
39 pub function: String,
41 pub source_lines: Vec<(usize, String)>,
43}
44
45impl StackFrame {
46 pub fn new(file: impl Into<String>, line: usize, function: impl Into<String>) -> Self {
54 Self {
55 file: file.into(),
56 line,
57 function: function.into(),
58 source_lines: Vec::new(),
59 }
60 }
61
62 pub fn load_source_snippet(&mut self, context: usize) {
70 if self.line == 0 || self.file.is_empty() {
71 return;
72 }
73
74 let content = match std::fs::read_to_string(&self.file) {
75 Ok(c) => c,
76 Err(_) => return, };
78
79 let lines: Vec<&str> = content.lines().collect();
80 let start = self.line.saturating_sub(context).max(1);
81 let end = (self.line + context).min(lines.len());
82
83 for (idx, line_content) in lines.iter().enumerate() {
84 let line_num = idx + 1;
85 if line_num >= start && line_num <= end {
86 self.source_lines.push((line_num, line_content.to_string()));
87 }
88 }
89 }
90}
91
92#[derive(Debug, Clone)]
97pub struct DebugError {
98 pub error_type: String,
100 pub message: String,
102 pub file: String,
104 pub line: usize,
106 pub stack: Vec<StackFrame>,
108 pub method: String,
110 pub uri: String,
112 pub headers: HashMap<String, String>,
114 pub query_params: HashMap<String, String>,
116}
117
118impl DebugError {
119 pub fn new(
121 error_type: impl Into<String>,
122 message: impl Into<String>,
123 file: impl Into<String>,
124 line: usize,
125 ) -> Self {
126 Self {
127 error_type: error_type.into(),
128 message: message.into(),
129 file: file.into(),
130 line,
131 stack: Vec::new(),
132 method: String::new(),
133 uri: String::new(),
134 headers: HashMap::new(),
135 query_params: HashMap::new(),
136 }
137 }
138
139 pub fn with_frame(mut self, frame: StackFrame) -> Self {
141 self.stack.push(frame);
142 self
143 }
144
145 pub fn with_request(
147 mut self,
148 method: impl Into<String>,
149 uri: impl Into<String>,
150 headers: HeaderMap,
151 query_params: HashMap<String, String>,
152 ) -> Self {
153 self.method = method.into();
154 self.uri = uri.into();
155 self.headers = sanitize_headers(&headers);
156 self.query_params = query_params;
157 self
158 }
159
160 pub fn with_source_snippet(mut self, context: usize) -> Self {
166 if !self.file.is_empty() && self.line > 0 {
168 let mut main_frame = StackFrame::new(self.file.clone(), self.line, "<main>");
169 main_frame.load_source_snippet(context);
170 if !main_frame.source_lines.is_empty() {
172 self.stack.insert(0, main_frame);
173 }
174 }
175
176 for frame in &mut self.stack {
178 if frame.source_lines.is_empty() {
179 frame.load_source_snippet(context);
180 }
181 }
182 self
183 }
184}
185
186#[derive(Debug, Clone)]
194pub struct DebugPageConfig {
195 pub debug_mode: bool,
197 pub source_context: usize,
199 pub show_stack: bool,
201 pub show_request: bool,
203 pub show_environment: bool,
205}
206
207impl Default for DebugPageConfig {
208 fn default() -> Self {
209 Self {
210 debug_mode: false,
211 source_context: 10,
212 show_stack: true,
213 show_request: true,
214 show_environment: true,
215 }
216 }
217}
218
219impl DebugPageConfig {
220 pub fn development() -> Self {
222 Self {
223 debug_mode: true,
224 source_context: 10,
225 show_stack: true,
226 show_request: true,
227 show_environment: true,
228 }
229 }
230
231 pub fn production() -> Self {
233 Self {
234 debug_mode: false,
235 source_context: 0,
236 show_stack: false,
237 show_request: false,
238 show_environment: false,
239 }
240 }
241}
242
243pub fn render_debug_html(error: &DebugError, config: &DebugPageConfig) -> String {
255 let title = html_escape(&format!("{}: {}", error.error_type, error.message));
256 let file_display = html_escape(&error.file);
257 let method_display = html_escape(&error.method);
258 let uri_display = html_escape(&error.uri);
259 let error_line = error.line;
261
262 let mut html = format!(
263 r#"<!DOCTYPE html>
264<html lang="zh-CN">
265<head>
266<meta charset="UTF-8">
267<title>{title}</title>
268<style>
269* {{ margin: 0; padding: 0; box-sizing: border-box; }}
270body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #fafafa; color: #333; }}
271.header {{ background: #d23f31; color: #fff; padding: 24px 32px; }}
272.header h1 {{ font-size: 22px; margin-bottom: 8px; word-break: break-all; }}
273.header .location {{ color: rgba(255,255,255,0.85); font-size: 13px; font-family: "Fira Code", monospace; }}
274.container {{ max-width: 1200px; margin: 24px auto; padding: 0 24px; }}
275.section {{ background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 16px; overflow: hidden; }}
276.section-title {{ background: #f5f5f5; padding: 12px 20px; border-bottom: 1px solid #e0e0e0; font-size: 14px; font-weight: 600; color: #555; }}
277.section-body {{ padding: 16px 20px; }}
278.stack-frame {{ border-bottom: 1px solid #eee; padding: 12px 0; }}
279.stack-frame:last-child {{ border-bottom: none; }}
280.frame-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }}
281.frame-function {{ color: #d23f31; font-family: "Fira Code", monospace; font-size: 13px; font-weight: 600; }}
282.frame-location {{ color: #888; font-family: "Fira Code", monospace; font-size: 12px; }}
283.source-list {{ background: #1e1e1e; border-radius: 4px; padding: 12px; overflow-x: auto; font-family: "Fira Code", monospace; font-size: 13px; }}
284.source-line {{ display: flex; color: #d4d4d4; }}
285.source-line.error {{ background: rgba(210,63,49,0.2); }}
286.line-num {{ color: #858585; min-width: 50px; text-align: right; padding-right: 16px; user-select: none; }}
287.line-content {{ white-space: pre; }}
288.request-table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
289.request-table th, .request-table td {{ text-align: left; padding: 8px 12px; border-bottom: 1px solid #eee; }}
290.request-table th {{ background: #fafafa; width: 200px; color: #555; font-weight: 600; }}
291.request-table td {{ font-family: "Fira Code", monospace; word-break: break-all; }}
292.env-grid {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; font-size: 13px; }}
293.env-item {{ padding: 8px 12px; background: #fafafa; border-radius: 4px; }}
294.env-item strong {{ color: #555; display: inline-block; min-width: 120px; }}
295.footer {{ text-align: center; padding: 24px; color: #999; font-size: 12px; }}
296</style>
297</head>
298<body>
299<div class="header">
300 <h1>{title}</h1>
301 <div class="location">{file_display}:{error_line}</div>
302</div>
303<div class="container">
304"#
305 );
306
307 if config.show_stack && !error.stack.is_empty() {
309 html.push_str("<div class=\"section\">\n");
310 html.push_str(" <div class=\"section-title\">Stack frames (");
311 html.push_str(&error.stack.len().to_string());
312 html.push_str(")</div>\n");
313 html.push_str(" <div class=\"section-body\">\n");
314 for frame in &error.stack {
315 html.push_str(&render_stack_frame_html(frame));
316 }
317 html.push_str(" </div>\n</div>\n");
318 }
319
320 if config.show_request && !error.method.is_empty() {
322 html.push_str("<div class=\"section\">\n");
323 html.push_str(" <div class=\"section-title\">Request</div>\n");
324 html.push_str(" <div class=\"section-body\">\n");
325 html.push_str(" <table class=\"request-table\">\n");
326 html.push_str(&format!(
327 " <tr><th>Method</th><td>{}</td></tr>\n",
328 method_display
329 ));
330 html.push_str(&format!(
331 " <tr><th>URI</th><td>{}</td></tr>\n",
332 uri_display
333 ));
334 for (key, value) in &error.headers {
335 html.push_str(&format!(
336 " <tr><th>{}</th><td>{}</td></tr>\n",
337 html_escape(key),
338 html_escape(value)
339 ));
340 }
341 for (key, value) in &error.query_params {
342 html.push_str(&format!(
343 " <tr><th>Query: {}</th><td>{}</td></tr>\n",
344 html_escape(key),
345 html_escape(value)
346 ));
347 }
348 html.push_str(" </table>\n </div>\n</div>\n");
349 }
350
351 if config.show_environment {
353 html.push_str("<div class=\"section\">\n");
354 html.push_str(" <div class=\"section-title\">Environment</div>\n");
355 html.push_str(" <div class=\"section-body\">\n");
356 html.push_str(" <div class=\"env-grid\">\n");
357 html.push_str(&format!(
358 " <div class=\"env-item\"><strong>Rust version</strong> {}</div>\n",
359 env!("CARGO_PKG_VERSION")
360 ));
361 html.push_str(&format!(
362 " <div class=\"env-item\"><strong>PID</strong> {}</div>\n",
363 std::process::id()
364 ));
365 if let Ok(cwd) = std::env::current_dir() {
366 html.push_str(&format!(
367 " <div class=\"env-item\"><strong>Working dir</strong> {}</div>\n",
368 html_escape(&cwd.display().to_string())
369 ));
370 }
371 let now = chrono::Local::now();
372 html.push_str(&format!(
373 " <div class=\"env-item\"><strong>Time</strong> {}</div>\n",
374 html_escape(&now.format("%Y-%m-%d %H:%M:%S").to_string())
375 ));
376 html.push_str(" </div>\n </div>\n</div>\n");
377 }
378
379 html.push_str("</div>\n");
380 html.push_str(
381 "<div class=\"footer\">SZ-Rust Whoops-style Debugger — debug mode enabled</div>\n",
382 );
383 html.push_str("</body>\n</html>\n");
384
385 html
386}
387
388fn render_stack_frame_html(frame: &StackFrame) -> String {
390 let function = html_escape(&frame.function);
391 let location = html_escape(&format!("{}:{}", frame.file, frame.line));
392
393 let mut html = format!(
394 r#" <div class="stack-frame">
395 <div class="frame-header">
396 <span class="frame-function">{function}</span>
397 <span class="frame-location">{location}</span>
398 </div>
399"#
400 );
401
402 if !frame.source_lines.is_empty() {
403 html.push_str(" <div class=\"source-list\">\n");
404 for (line_num, content) in &frame.source_lines {
405 let is_error_line = *line_num == frame.line;
406 let line_class = if is_error_line { " error" } else { "" };
407 html.push_str(&format!(
408 " <div class=\"source-line{}\"><span class=\"line-num\">{}</span><span class=\"line-content\">{}</span></div>\n",
409 line_class,
410 line_num,
411 html_escape(content)
412 ));
413 }
414 html.push_str(" </div>\n");
415 }
416
417 html.push_str(" </div>\n");
418 html
419}
420
421pub fn render_production_html(status: StatusCode, message: &str) -> String {
423 let status_code = status.as_u16();
424 let status_text = html_escape(status.canonical_reason().unwrap_or("Error"));
425 let msg = html_escape(message);
426
427 format!(
428 r#"<!DOCTYPE html>
429<html lang="zh-CN">
430<head>
431<meta charset="UTF-8">
432<title>{status_code} {status_text}</title>
433<style>
434body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #fafafa; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; }}
435.error-box {{ background: #fff; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); padding: 48px 64px; text-align: center; max-width: 480px; }}
436.error-code {{ font-size: 72px; font-weight: 700; color: #d23f31; line-height: 1; margin-bottom: 16px; }}
437.error-title {{ font-size: 20px; color: #555; margin-bottom: 8px; }}
438.error-message {{ font-size: 14px; color: #999; }}
439</style>
440</head>
441<body>
442<div class="error-box">
443 <div class="error-code">{status_code}</div>
444 <div class="error-title">{status_text}</div>
445 <div class="error-message">{msg}</div>
446</div>
447</body>
448</html>
449"#
450 )
451}
452
453pub fn debug_error_response(
467 status: StatusCode,
468 error: &DebugError,
469 config: &DebugPageConfig,
470) -> Response {
471 let html = if config.debug_mode {
472 render_debug_html(error, config)
473 } else {
474 let safe_message = status.canonical_reason().unwrap_or("Error");
476 render_production_html(status, safe_message)
477 };
478
479 (
480 status,
481 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
482 html,
483 )
484 .into_response()
485}
486
487pub fn from_panic(panic_info: &std::panic::PanicHookInfo<'_>) -> DebugError {
493 let message = panic_info
494 .payload()
495 .downcast_ref::<&str>()
496 .map(|s| s.to_string())
497 .or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
498 .unwrap_or_else(|| "<unknown panic payload>".to_string());
499
500 let (file, line) = panic_info
501 .location()
502 .map(|loc| (loc.file().to_string(), loc.line() as usize))
503 .unwrap_or_else(|| ("<unknown>".to_string(), 0));
504
505 DebugError::new("panic", message, file, line)
506}
507
508fn html_escape(s: &str) -> String {
516 let mut escaped = String::with_capacity(s.len());
517 for ch in s.chars() {
518 match ch {
519 '&' => escaped.push_str("&"),
520 '<' => escaped.push_str("<"),
521 '>' => escaped.push_str(">"),
522 '"' => escaped.push_str("""),
523 '\'' => escaped.push_str("'"),
524 _ => escaped.push(ch),
525 }
526 }
527 escaped
528}
529
530fn sanitize_headers(headers: &HeaderMap) -> HashMap<String, String> {
535 const SENSITIVE_HEADERS: &[&str] = &[
536 "authorization",
537 "cookie",
538 "set-cookie",
539 "x-api-key",
540 "x-auth-token",
541 ];
542
543 let mut sanitized = HashMap::new();
544 for (name, value) in headers.iter() {
545 let name_str = name.as_str().to_lowercase();
546 let value_str = if SENSITIVE_HEADERS.contains(&name_str.as_str()) {
547 "<redacted>".to_string()
548 } else {
549 value.to_str().unwrap_or("<binary>").to_string()
550 };
551 sanitized.insert(name_str, value_str);
552 }
553 sanitized
554}
555
556#[derive(Debug, Clone, serde::Serialize)]
565pub struct DbQueryLocation {
566 pub file: String,
568 pub line: u32,
570 pub function: Option<String>,
572}
573
574#[derive(Debug, Clone, serde::Serialize)]
579pub struct DbQuery {
580 pub sql: String,
582 pub bindings: Vec<serde_json::Value>,
584 pub duration_ms: u64,
586 pub backtrace: Vec<DbQueryLocation>,
588 pub query_type: String,
590 pub connection: String,
592}
593
594#[derive(Debug, Default)]
599pub struct DbQueryCollector {
600 queries: Mutex<Vec<DbQuery>>,
602 enabled: Mutex<bool>,
604}
605
606impl DbQueryCollector {
607 pub fn new() -> Self {
609 Self::default()
610 }
611
612 pub fn enabled(&self) -> bool {
614 *self.enabled.lock()
615 }
616
617 pub fn set_enabled(&self, enabled: bool) {
619 *self.enabled.lock() = enabled;
620 }
621
622 pub fn add_query(&self, query: DbQuery) {
624 if self.enabled() {
625 self.queries.lock().push(query);
626 }
627 }
628
629 pub fn queries(&self) -> Vec<DbQuery> {
631 self.queries.lock().clone()
632 }
633
634 pub fn total_duration_ms(&self) -> u64 {
636 self.queries.lock().iter().map(|q| q.duration_ms).sum()
637 }
638
639 pub fn clear(&self) {
641 self.queries.lock().clear();
642 }
643
644 pub fn count(&self) -> usize {
646 self.queries.lock().len()
647 }
648}
649
650pub static GLOBAL_DB_QUERY_COLLECTOR: Lazy<DbQueryCollector> = Lazy::new(DbQueryCollector::new);
655
656#[derive(Debug, Clone, Copy, PartialEq, Eq)]
664pub enum Editor {
665 VsCode,
667 PhpStorm,
669 Sublime,
671 Atom,
673 Ide,
675}
676
677pub fn editor_url(editor: Editor, file: &str, line: u32) -> String {
693 match editor {
694 Editor::VsCode => format!("vscode://file/{}:{}", file, line),
695 Editor::PhpStorm => format!("phpstorm://open?file={}&line={}", file, line),
696 Editor::Sublime => format!("subl://open?url=file://{}&line={}", file, line),
697 Editor::Atom => format!("atom://core/open/file?filename={}&line={}", file, line),
698 Editor::Ide => format!("idea://open?file={}&line={}", file, line),
699 }
700}
701
702pub fn render_db_queries_html(queries: &[DbQuery], editor: Editor) -> String {
711 if queries.is_empty() {
712 return String::new();
713 }
714
715 let mut html = String::new();
716 html.push_str("<div class=\"section\">\n");
717 html.push_str(" <div class=\"section-title\">Database Queries (");
718 html.push_str(&queries.len().to_string());
719 html.push_str(")</div>\n");
720 html.push_str(" <div class=\"section-body\">\n");
721
722 for (idx, query) in queries.iter().enumerate() {
723 html.push_str(&render_db_query_html(query, idx + 1, editor));
724 }
725
726 html.push_str(" </div>\n</div>\n");
727 html
728}
729
730fn render_db_query_html(query: &DbQuery, index: usize, editor: Editor) -> String {
732 let sql = html_escape(&query.sql);
733 let query_type = html_escape(&query.query_type);
734 let connection = html_escape(&query.connection);
735
736 let mut html = format!(
737 r#" <div class="db-query" style="border-bottom: 1px solid #eee; padding: 12px 0;">
738 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
739 <span style="color: #d23f31; font-family: 'Fira Code', monospace; font-size: 13px; font-weight: 600;">#{index} {query_type}</span>
740 <span style="color: #888; font-family: 'Fira Code', monospace; font-size: 12px;">{connection} • {duration}ms</span>
741 </div>
742 <div style="background: #1e1e1e; color: #d4d4d4; border-radius: 4px; padding: 12px; font-family: 'Fira Code', monospace; font-size: 13px; white-space: pre-wrap; word-break: break-all; margin-bottom: 8px;">{sql}</div>
743"#,
744 index = index,
745 query_type = query_type,
746 connection = connection,
747 duration = query.duration_ms,
748 sql = sql
749 );
750
751 if !query.bindings.is_empty() {
753 html.push_str(" <div style=\"margin-bottom: 8px;\">\n");
754 for (i, binding) in query.bindings.iter().enumerate() {
755 html.push_str(&format!(
756 " <span style=\"display: inline-block; background: #f5f5f5; border-radius: 3px; padding: 2px 8px; margin-right: 4px; font-family: 'Fira Code', monospace; font-size: 12px;\">{}: {}</span>\n",
757 i + 1,
758 html_escape(&binding.to_string())
759 ));
760 }
761 html.push_str(" </div>\n");
762 }
763
764 if !query.backtrace.is_empty() {
766 html.push_str(
767 " <div style=\"font-family: 'Fira Code', monospace; font-size: 12px;\">\n",
768 );
769 for loc in &query.backtrace {
770 let url = html_escape(&editor_url(editor, &loc.file, loc.line));
771 let location = html_escape(&format!("{}:{}", loc.file, loc.line));
772 let func = match &loc.function {
773 Some(f) => html_escape(f),
774 None => "<anonymous>".to_string(),
775 };
776 html.push_str(&format!(
777 " <a href=\"{}\" style=\"color: #4a90d9; text-decoration: none; display: block; padding: 2px 0;\">{} ({})</a>\n",
778 url, location, func
779 ));
780 }
781 html.push_str(" </div>\n");
782 }
783
784 html.push_str(" </div>\n");
785 html
786}
787
788#[cfg(test)]
793mod tests {
794 use super::*;
795 use axum::http::{HeaderValue, Method};
796
797 #[test]
802 fn test_html_escape_basic() {
803 assert_eq!(html_escape("hello"), "hello");
804 assert_eq!(html_escape("<script>"), "<script>");
805 assert_eq!(html_escape("\"quote\""), ""quote"");
806 assert_eq!(html_escape("'apos'"), "'apos'");
807 assert_eq!(html_escape("a & b"), "a & b");
808 }
809
810 #[test]
811 fn test_html_escape_empty() {
812 assert_eq!(html_escape(""), "");
813 }
814
815 #[test]
816 fn test_html_escape_xss_payload() {
817 let payload = "<script>alert('XSS')</script>";
818 let escaped = html_escape(payload);
819 assert!(!escaped.contains('<'));
820 assert!(!escaped.contains('>'));
821 assert!(escaped.contains("<script>"));
822 }
823
824 #[test]
829 fn test_stack_frame_load_source_snippet() {
830 let temp = tempfile::tempdir().unwrap();
831 let file_path = temp.path().join("test.rs");
832 std::fs::write(
833 &file_path,
834 "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\n",
835 )
836 .unwrap();
837
838 let mut frame = StackFrame::new(file_path.to_str().unwrap(), 5, "test_fn");
839 frame.load_source_snippet(2);
840
841 assert_eq!(frame.source_lines.len(), 5);
843 assert_eq!(frame.source_lines[0].0, 3);
844 assert_eq!(frame.source_lines[0].1, "line3");
845 assert_eq!(frame.source_lines[2].0, 5);
846 assert_eq!(frame.source_lines[2].1, "line5");
847 assert_eq!(frame.source_lines[4].0, 7);
848 assert_eq!(frame.source_lines[4].1, "line7");
849 }
850
851 #[test]
852 fn test_stack_frame_load_source_snippet_at_file_start() {
853 let temp = tempfile::tempdir().unwrap();
854 let file_path = temp.path().join("start.rs");
855 std::fs::write(&file_path, "line1\nline2\nline3\n").unwrap();
856
857 let mut frame = StackFrame::new(file_path.to_str().unwrap(), 1, "first");
858 frame.load_source_snippet(10);
859
860 assert_eq!(frame.source_lines.len(), 3);
862 assert_eq!(frame.source_lines[0].0, 1);
863 }
864
865 #[test]
866 fn test_stack_frame_load_source_snippet_nonexistent_file() {
867 let mut frame = StackFrame::new("/nonexistent/file.rs", 10, "missing");
868 frame.load_source_snippet(5);
869 assert!(frame.source_lines.is_empty());
870 }
871
872 #[test]
873 fn test_stack_frame_load_source_snippet_zero_line() {
874 let mut frame = StackFrame::new("test.rs", 0, "unknown");
875 frame.load_source_snippet(5);
876 assert!(frame.source_lines.is_empty());
877 }
878
879 #[test]
884 fn test_debug_page_config_default() {
885 let config = DebugPageConfig::default();
886 assert!(!config.debug_mode);
887 assert_eq!(config.source_context, 10);
888 }
889
890 #[test]
891 fn test_debug_page_config_development() {
892 let config = DebugPageConfig::development();
893 assert!(config.debug_mode);
894 assert!(config.show_stack);
895 assert!(config.show_request);
896 assert!(config.show_environment);
897 }
898
899 #[test]
900 fn test_debug_page_config_production() {
901 let config = DebugPageConfig::production();
902 assert!(!config.debug_mode);
903 assert!(!config.show_stack);
904 assert_eq!(config.source_context, 0);
905 }
906
907 #[test]
912 fn test_render_debug_html_contains_error_info() {
913 let error = DebugError::new("TestError", "test message", "test.rs", 42);
914 let config = DebugPageConfig::development();
915 let html = render_debug_html(&error, &config);
916
917 assert!(html.contains("TestError"));
918 assert!(html.contains("test message"));
919 assert!(html.contains("test.rs:42"));
920 assert!(html.contains("<!DOCTYPE html>"));
921 }
922
923 #[test]
924 fn test_render_debug_html_escapes_xss() {
925 let error = DebugError::new(
926 "<script>alert('xss')</script>",
927 "<img src=x onerror=alert(1)>",
928 "test.rs",
929 1,
930 );
931 let config = DebugPageConfig::development();
932 let html = render_debug_html(&error, &config);
933
934 assert!(!html.contains("<script>alert"));
936 assert!(html.contains("<script>"));
938 assert!(html.contains("<img"));
939 }
940
941 #[test]
942 fn test_render_debug_html_shows_stack() {
943 let error = DebugError::new("Err", "msg", "test.rs", 1)
944 .with_frame(StackFrame::new("frame1.rs", 10, "func1"))
945 .with_frame(StackFrame::new("frame2.rs", 20, "func2"));
946 let config = DebugPageConfig::development();
947 let html = render_debug_html(&error, &config);
948
949 assert!(html.contains("func1"));
950 assert!(html.contains("func2"));
951 assert!(html.contains("frame1.rs:10"));
952 assert!(html.contains("frame2.rs:20"));
953 assert!(html.contains("Stack frames (2)"));
954 }
955
956 #[test]
957 fn test_render_debug_html_hides_stack_when_disabled() {
958 let error = DebugError::new("Err", "msg", "test.rs", 1).with_frame(StackFrame::new(
959 "frame1.rs",
960 10,
961 "func1",
962 ));
963 let mut config = DebugPageConfig::development();
964 config.show_stack = false;
965 let html = render_debug_html(&error, &config);
966
967 assert!(!html.contains("func1"));
968 assert!(!html.contains("Stack frames"));
969 }
970
971 #[test]
972 fn test_render_debug_html_shows_request_info() {
973 let mut headers = HeaderMap::new();
974 headers.insert("x-custom", HeaderValue::from_static("value1"));
975 let mut query = HashMap::new();
976 query.insert("id".to_string(), "123".to_string());
977
978 let error = DebugError::new("Err", "msg", "test.rs", 1).with_request(
979 Method::GET.as_str(),
980 "/api/users",
981 headers,
982 query,
983 );
984 let config = DebugPageConfig::development();
985 let html = render_debug_html(&error, &config);
986
987 assert!(html.contains("GET"));
988 assert!(html.contains("/api/users"));
989 assert!(html.contains("x-custom"));
990 assert!(html.contains("value1"));
991 assert!(html.contains("Query: id"));
992 assert!(html.contains("123"));
993 }
994
995 #[test]
996 fn test_render_debug_html_redacts_sensitive_headers() {
997 let mut headers = HeaderMap::new();
998 headers.insert(
999 "authorization",
1000 HeaderValue::from_static("Bearer secret123"),
1001 );
1002 headers.insert("cookie", HeaderValue::from_static("session=abc"));
1003
1004 let error = DebugError::new("Err", "msg", "test.rs", 1).with_request(
1005 "POST",
1006 "/api",
1007 headers,
1008 HashMap::new(),
1009 );
1010 let config = DebugPageConfig::development();
1011 let html = render_debug_html(&error, &config);
1012
1013 assert!(!html.contains("secret123"));
1015 assert!(!html.contains("session=abc"));
1016 assert!(html.contains("<redacted>"));
1018 }
1019
1020 #[test]
1021 fn test_render_debug_html_shows_environment() {
1022 let error = DebugError::new("Err", "msg", "test.rs", 1);
1023 let config = DebugPageConfig::development();
1024 let html = render_debug_html(&error, &config);
1025
1026 assert!(html.contains("PID"));
1027 assert!(html.contains("Rust version"));
1028 }
1029
1030 #[tokio::test]
1031 async fn test_render_production_html_no_stack() {
1032 let error = DebugError::new("SecretError", "internal db password=xxx", "secret.rs", 100)
1033 .with_frame(StackFrame::new("frame.rs", 1, "secret_func"));
1034 let config = DebugPageConfig::production();
1035 let response = debug_error_response(StatusCode::INTERNAL_SERVER_ERROR, &error, &config);
1038 use http_body_util::BodyExt;
1040 let bytes = response.into_body().collect().await.unwrap().to_bytes();
1041 let html = String::from_utf8(bytes.to_vec()).unwrap();
1042
1043 assert!(!html.contains("SecretError"));
1045 assert!(!html.contains("secret.rs"));
1046 assert!(!html.contains("secret_func"));
1047 assert!(!html.contains("password=xxx"));
1048 }
1049
1050 #[test]
1055 fn test_render_production_html_basic() {
1056 let html = render_production_html(StatusCode::INTERNAL_SERVER_ERROR, "Server Error");
1057 assert!(html.contains("500"));
1058 assert!(html.contains("Internal Server Error"));
1059 assert!(html.contains("Server Error"));
1060 assert!(html.contains("<!DOCTYPE html>"));
1061 }
1062
1063 #[test]
1064 fn test_render_production_html_escapes_message() {
1065 let html = render_production_html(StatusCode::BAD_REQUEST, "<script>alert('xss')</script>");
1066 assert!(!html.contains("<script>alert"));
1067 assert!(html.contains("<script>"));
1068 }
1069
1070 #[tokio::test]
1075 async fn test_debug_error_response_debug_mode() {
1076 let error = DebugError::new("TestError", "test", "test.rs", 1);
1077 let config = DebugPageConfig::development();
1078 let response = debug_error_response(StatusCode::INTERNAL_SERVER_ERROR, &error, &config);
1079
1080 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1081 assert_eq!(
1082 response.headers().get("content-type").unwrap(),
1083 "text/html; charset=utf-8"
1084 );
1085 }
1086
1087 #[tokio::test]
1088 async fn test_debug_error_response_production_mode() {
1089 let error = DebugError::new("TestError", "secret", "test.rs", 1);
1090 let config = DebugPageConfig::production();
1091 let response = debug_error_response(StatusCode::NOT_FOUND, &error, &config);
1092
1093 assert_eq!(response.status(), StatusCode::NOT_FOUND);
1094 assert_eq!(
1095 response.headers().get("content-type").unwrap(),
1096 "text/html; charset=utf-8"
1097 );
1098 }
1099
1100 #[test]
1105 fn test_sanitize_headers_redacts_authorization() {
1106 let mut headers = HeaderMap::new();
1107 headers.insert("authorization", HeaderValue::from_static("Bearer xyz"));
1108 headers.insert("cookie", HeaderValue::from_static("sess=abc"));
1109 headers.insert("x-custom", HeaderValue::from_static("safe"));
1110
1111 let sanitized = sanitize_headers(&headers);
1112
1113 assert_eq!(sanitized.get("authorization").unwrap(), "<redacted>");
1114 assert_eq!(sanitized.get("cookie").unwrap(), "<redacted>");
1115 assert_eq!(sanitized.get("x-custom").unwrap(), "safe");
1116 }
1117
1118 #[test]
1119 fn test_sanitize_headers_empty() {
1120 let headers = HeaderMap::new();
1121 let sanitized = sanitize_headers(&headers);
1122 assert!(sanitized.is_empty());
1123 }
1124
1125 #[test]
1130 fn test_debug_error_builder_pattern() {
1131 let error = DebugError::new("ErrType", "msg", "file.rs", 10)
1132 .with_frame(StackFrame::new("frame.rs", 5, "fn1"));
1133
1134 assert_eq!(error.error_type, "ErrType");
1135 assert_eq!(error.message, "msg");
1136 assert_eq!(error.file, "file.rs");
1137 assert_eq!(error.line, 10);
1138 assert_eq!(error.stack.len(), 1);
1139 assert_eq!(error.stack[0].function, "fn1");
1140 }
1141
1142 #[test]
1143 fn test_debug_error_with_source_snippet_loads_main_frame() {
1144 let temp = tempfile::tempdir().unwrap();
1145 let file_path = temp.path().join("main.rs");
1146 std::fs::write(&file_path, "line1\nline2\nline3\nline4\nline5\n").unwrap();
1147
1148 let error =
1149 DebugError::new("Err", "msg", file_path.to_str().unwrap(), 3).with_source_snippet(1);
1150
1151 assert!(!error.stack.is_empty());
1153 assert_eq!(error.stack[0].line, 3);
1154 assert!(!error.stack[0].source_lines.is_empty());
1155 }
1156
1157 #[test]
1162 fn test_db_query_location_serialize() {
1163 let loc = DbQueryLocation {
1164 file: "/path/to/file.rs".to_string(),
1165 line: 42,
1166 function: Some("query_users".to_string()),
1167 };
1168 let json = serde_json::to_value(&loc).unwrap();
1169 assert_eq!(json["file"], "/path/to/file.rs");
1170 assert_eq!(json["line"], 42);
1171 assert_eq!(json["function"], "query_users");
1172 }
1173
1174 #[test]
1175 fn test_db_query_serialize() {
1176 let query = DbQuery {
1177 sql: "SELECT * FROM users WHERE id = ?".to_string(),
1178 bindings: vec![serde_json::json!(1)],
1179 duration_ms: 15,
1180 backtrace: vec![DbQueryLocation {
1181 file: "src/db.rs".to_string(),
1182 line: 100,
1183 function: Some("find_user".to_string()),
1184 }],
1185 query_type: "select".to_string(),
1186 connection: "mysql".to_string(),
1187 };
1188 let json = serde_json::to_value(&query).unwrap();
1189 assert_eq!(json["sql"], "SELECT * FROM users WHERE id = ?");
1190 assert_eq!(json["bindings"][0], 1);
1191 assert_eq!(json["duration_ms"], 15);
1192 assert_eq!(json["backtrace"][0]["file"], "src/db.rs");
1193 assert_eq!(json["backtrace"][0]["line"], 100);
1194 assert_eq!(json["query_type"], "select");
1195 assert_eq!(json["connection"], "mysql");
1196 }
1197
1198 #[test]
1203 fn test_db_query_collector_new() {
1204 let collector = DbQueryCollector::new();
1205 assert!(!collector.enabled());
1206 assert_eq!(collector.count(), 0);
1207 assert!(collector.queries().is_empty());
1208 assert_eq!(collector.total_duration_ms(), 0);
1209 }
1210
1211 #[test]
1212 fn test_db_query_collector_add_query() {
1213 let collector = DbQueryCollector::new();
1214 collector.set_enabled(true);
1215
1216 let query = DbQuery {
1217 sql: "SELECT * FROM users".to_string(),
1218 bindings: vec![serde_json::json!(1)],
1219 duration_ms: 10,
1220 backtrace: vec![],
1221 query_type: "select".to_string(),
1222 connection: "mysql".to_string(),
1223 };
1224 collector.add_query(query);
1225 assert_eq!(collector.count(), 1);
1226
1227 collector.set_enabled(false);
1229 collector.add_query(DbQuery {
1230 sql: "SELECT 1".to_string(),
1231 bindings: vec![],
1232 duration_ms: 5,
1233 backtrace: vec![],
1234 query_type: "select".to_string(),
1235 connection: "mysql".to_string(),
1236 });
1237 assert_eq!(collector.count(), 1);
1238 }
1239
1240 #[test]
1241 fn test_db_query_collector_enabled() {
1242 let collector = DbQueryCollector::new();
1243 assert!(!collector.enabled());
1244 collector.set_enabled(true);
1245 assert!(collector.enabled());
1246 collector.set_enabled(false);
1247 assert!(!collector.enabled());
1248 }
1249
1250 #[test]
1251 fn test_db_query_collector_total_duration() {
1252 let collector = DbQueryCollector::new();
1253 collector.set_enabled(true);
1254 collector.add_query(DbQuery {
1255 sql: "SELECT 1".to_string(),
1256 bindings: vec![],
1257 duration_ms: 10,
1258 backtrace: vec![],
1259 query_type: "select".to_string(),
1260 connection: "mysql".to_string(),
1261 });
1262 collector.add_query(DbQuery {
1263 sql: "SELECT 2".to_string(),
1264 bindings: vec![],
1265 duration_ms: 25,
1266 backtrace: vec![],
1267 query_type: "select".to_string(),
1268 connection: "mysql".to_string(),
1269 });
1270 assert_eq!(collector.total_duration_ms(), 35);
1271 }
1272
1273 #[test]
1274 fn test_db_query_collector_clear() {
1275 let collector = DbQueryCollector::new();
1276 collector.set_enabled(true);
1277 collector.add_query(DbQuery {
1278 sql: "SELECT 1".to_string(),
1279 bindings: vec![],
1280 duration_ms: 10,
1281 backtrace: vec![],
1282 query_type: "select".to_string(),
1283 connection: "mysql".to_string(),
1284 });
1285 assert_eq!(collector.count(), 1);
1286 collector.clear();
1287 assert_eq!(collector.count(), 0);
1288 assert_eq!(collector.total_duration_ms(), 0);
1289 }
1290
1291 #[test]
1292 fn test_db_query_collector_count() {
1293 let collector = DbQueryCollector::new();
1294 collector.set_enabled(true);
1295 assert_eq!(collector.count(), 0);
1296 collector.add_query(DbQuery {
1297 sql: "SELECT 1".to_string(),
1298 bindings: vec![],
1299 duration_ms: 1,
1300 backtrace: vec![],
1301 query_type: "select".to_string(),
1302 connection: "mysql".to_string(),
1303 });
1304 assert_eq!(collector.count(), 1);
1305 collector.add_query(DbQuery {
1306 sql: "SELECT 2".to_string(),
1307 bindings: vec![],
1308 duration_ms: 2,
1309 backtrace: vec![],
1310 query_type: "select".to_string(),
1311 connection: "mysql".to_string(),
1312 });
1313 assert_eq!(collector.count(), 2);
1314 }
1315
1316 #[test]
1321 fn test_editor_url_vscode() {
1322 let url = editor_url(Editor::VsCode, "/path/to/file.rs", 42);
1323 assert_eq!(url, "vscode://file//path/to/file.rs:42");
1324 }
1325
1326 #[test]
1327 fn test_editor_url_phpstorm() {
1328 let url = editor_url(Editor::PhpStorm, "/path/to/file.rs", 10);
1329 assert_eq!(url, "phpstorm://open?file=/path/to/file.rs&line=10");
1330 }
1331
1332 #[test]
1333 fn test_editor_url_sublime() {
1334 let url = editor_url(Editor::Sublime, "/path/to/file.rs", 5);
1335 assert_eq!(url, "subl://open?url=file:///path/to/file.rs&line=5");
1336 }
1337
1338 #[test]
1339 fn test_editor_url_atom() {
1340 let url = editor_url(Editor::Atom, "/path/to/file.rs", 15);
1341 assert_eq!(
1342 url,
1343 "atom://core/open/file?filename=/path/to/file.rs&line=15"
1344 );
1345 }
1346
1347 #[test]
1348 fn test_editor_url_ide() {
1349 let url = editor_url(Editor::Ide, "/path/to/file.rs", 20);
1350 assert_eq!(url, "idea://open?file=/path/to/file.rs&line=20");
1351 }
1352
1353 #[test]
1358 fn test_render_db_queries_html_empty() {
1359 let queries: Vec<DbQuery> = vec![];
1360 let html = render_db_queries_html(&queries, Editor::VsCode);
1361 assert!(html.is_empty());
1362 }
1363
1364 #[test]
1365 fn test_render_db_queries_html_with_queries() {
1366 let queries = vec![DbQuery {
1367 sql: "SELECT * FROM users WHERE id = ?".to_string(),
1368 bindings: vec![serde_json::json!(1)],
1369 duration_ms: 15,
1370 backtrace: vec![DbQueryLocation {
1371 file: "src/db.rs".to_string(),
1372 line: 100,
1373 function: Some("find_user".to_string()),
1374 }],
1375 query_type: "select".to_string(),
1376 connection: "mysql".to_string(),
1377 }];
1378 let html = render_db_queries_html(&queries, Editor::VsCode);
1379
1380 assert!(html.contains("Database Queries (1)"));
1381 assert!(html.contains("SELECT * FROM users WHERE id = ?"));
1382 assert!(html.contains("#1 select"));
1383 assert!(html.contains("mysql"));
1384 assert!(html.contains("15ms"));
1385 assert!(html.contains("src/db.rs:100"));
1386 assert!(html.contains("find_user"));
1387 }
1388
1389 #[test]
1390 fn test_render_db_queries_html_contains_editor_link() {
1391 let queries = vec![DbQuery {
1392 sql: "SELECT 1".to_string(),
1393 bindings: vec![],
1394 duration_ms: 1,
1395 backtrace: vec![DbQueryLocation {
1396 file: "/app/src/main.rs".to_string(),
1397 line: 42,
1398 function: Some("main".to_string()),
1399 }],
1400 query_type: "select".to_string(),
1401 connection: "default".to_string(),
1402 }];
1403 let html = render_db_queries_html(&queries, Editor::VsCode);
1404
1405 assert!(html.contains("href=\""));
1407 assert!(html.contains("vscode://file//app/src/main.rs:42"));
1408 }
1409
1410 #[test]
1415 fn test_global_db_query_collector() {
1416 let was_enabled = GLOBAL_DB_QUERY_COLLECTOR.enabled();
1418
1419 GLOBAL_DB_QUERY_COLLECTOR.clear();
1421 assert_eq!(GLOBAL_DB_QUERY_COLLECTOR.count(), 0);
1422
1423 GLOBAL_DB_QUERY_COLLECTOR.set_enabled(true);
1425 GLOBAL_DB_QUERY_COLLECTOR.add_query(DbQuery {
1426 sql: "SELECT 1".to_string(),
1427 bindings: vec![],
1428 duration_ms: 1,
1429 backtrace: vec![],
1430 query_type: "select".to_string(),
1431 connection: "default".to_string(),
1432 });
1433 assert_eq!(GLOBAL_DB_QUERY_COLLECTOR.count(), 1);
1434
1435 GLOBAL_DB_QUERY_COLLECTOR.clear();
1437 GLOBAL_DB_QUERY_COLLECTOR.set_enabled(was_enabled);
1438 }
1439}