1use axum::http::{HeaderMap, StatusCode};
21use axum::response::{IntoResponse, Response};
22use std::collections::HashMap;
23
24#[derive(Debug, Clone)]
32pub struct StackFrame {
33 pub file: String,
35 pub line: usize,
37 pub function: String,
39 pub source_lines: Vec<(usize, String)>,
41}
42
43impl StackFrame {
44 pub fn new(file: impl Into<String>, line: usize, function: impl Into<String>) -> Self {
52 Self {
53 file: file.into(),
54 line,
55 function: function.into(),
56 source_lines: Vec::new(),
57 }
58 }
59
60 pub fn load_source_snippet(&mut self, context: usize) {
68 if self.line == 0 || self.file.is_empty() {
69 return;
70 }
71
72 let content = match std::fs::read_to_string(&self.file) {
73 Ok(c) => c,
74 Err(_) => return, };
76
77 let lines: Vec<&str> = content.lines().collect();
78 let start = self.line.saturating_sub(context).max(1);
79 let end = (self.line + context).min(lines.len());
80
81 for (idx, line_content) in lines.iter().enumerate() {
82 let line_num = idx + 1;
83 if line_num >= start && line_num <= end {
84 self.source_lines.push((line_num, line_content.to_string()));
85 }
86 }
87 }
88}
89
90#[derive(Debug, Clone)]
95pub struct DebugError {
96 pub error_type: String,
98 pub message: String,
100 pub file: String,
102 pub line: usize,
104 pub stack: Vec<StackFrame>,
106 pub method: String,
108 pub uri: String,
110 pub headers: HashMap<String, String>,
112 pub query_params: HashMap<String, String>,
114}
115
116impl DebugError {
117 pub fn new(
119 error_type: impl Into<String>,
120 message: impl Into<String>,
121 file: impl Into<String>,
122 line: usize,
123 ) -> Self {
124 Self {
125 error_type: error_type.into(),
126 message: message.into(),
127 file: file.into(),
128 line,
129 stack: Vec::new(),
130 method: String::new(),
131 uri: String::new(),
132 headers: HashMap::new(),
133 query_params: HashMap::new(),
134 }
135 }
136
137 pub fn with_frame(mut self, frame: StackFrame) -> Self {
139 self.stack.push(frame);
140 self
141 }
142
143 pub fn with_request(
145 mut self,
146 method: impl Into<String>,
147 uri: impl Into<String>,
148 headers: HeaderMap,
149 query_params: HashMap<String, String>,
150 ) -> Self {
151 self.method = method.into();
152 self.uri = uri.into();
153 self.headers = sanitize_headers(&headers);
154 self.query_params = query_params;
155 self
156 }
157
158 pub fn with_source_snippet(mut self, context: usize) -> Self {
164 if !self.file.is_empty() && self.line > 0 {
166 let mut main_frame = StackFrame::new(self.file.clone(), self.line, "<main>");
167 main_frame.load_source_snippet(context);
168 if !main_frame.source_lines.is_empty() {
170 self.stack.insert(0, main_frame);
171 }
172 }
173
174 for frame in &mut self.stack {
176 if frame.source_lines.is_empty() {
177 frame.load_source_snippet(context);
178 }
179 }
180 self
181 }
182}
183
184#[derive(Debug, Clone)]
192pub struct DebugPageConfig {
193 pub debug_mode: bool,
195 pub source_context: usize,
197 pub show_stack: bool,
199 pub show_request: bool,
201 pub show_environment: bool,
203}
204
205impl Default for DebugPageConfig {
206 fn default() -> Self {
207 Self {
208 debug_mode: false,
209 source_context: 10,
210 show_stack: true,
211 show_request: true,
212 show_environment: true,
213 }
214 }
215}
216
217impl DebugPageConfig {
218 pub fn development() -> Self {
220 Self {
221 debug_mode: true,
222 source_context: 10,
223 show_stack: true,
224 show_request: true,
225 show_environment: true,
226 }
227 }
228
229 pub fn production() -> Self {
231 Self {
232 debug_mode: false,
233 source_context: 0,
234 show_stack: false,
235 show_request: false,
236 show_environment: false,
237 }
238 }
239}
240
241pub fn render_debug_html(error: &DebugError, config: &DebugPageConfig) -> String {
253 let title = html_escape(&format!("{}: {}", error.error_type, error.message));
254 let file_display = html_escape(&error.file);
255 let method_display = html_escape(&error.method);
256 let uri_display = html_escape(&error.uri);
257 let error_line = error.line;
259
260 let mut html = format!(
261 r#"<!DOCTYPE html>
262<html lang="zh-CN">
263<head>
264<meta charset="UTF-8">
265<title>{title}</title>
266<style>
267* {{ margin: 0; padding: 0; box-sizing: border-box; }}
268body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #fafafa; color: #333; }}
269.header {{ background: #d23f31; color: #fff; padding: 24px 32px; }}
270.header h1 {{ font-size: 22px; margin-bottom: 8px; word-break: break-all; }}
271.header .location {{ color: rgba(255,255,255,0.85); font-size: 13px; font-family: "Fira Code", monospace; }}
272.container {{ max-width: 1200px; margin: 24px auto; padding: 0 24px; }}
273.section {{ background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); margin-bottom: 16px; overflow: hidden; }}
274.section-title {{ background: #f5f5f5; padding: 12px 20px; border-bottom: 1px solid #e0e0e0; font-size: 14px; font-weight: 600; color: #555; }}
275.section-body {{ padding: 16px 20px; }}
276.stack-frame {{ border-bottom: 1px solid #eee; padding: 12px 0; }}
277.stack-frame:last-child {{ border-bottom: none; }}
278.frame-header {{ display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }}
279.frame-function {{ color: #d23f31; font-family: "Fira Code", monospace; font-size: 13px; font-weight: 600; }}
280.frame-location {{ color: #888; font-family: "Fira Code", monospace; font-size: 12px; }}
281.source-list {{ background: #1e1e1e; border-radius: 4px; padding: 12px; overflow-x: auto; font-family: "Fira Code", monospace; font-size: 13px; }}
282.source-line {{ display: flex; color: #d4d4d4; }}
283.source-line.error {{ background: rgba(210,63,49,0.2); }}
284.line-num {{ color: #858585; min-width: 50px; text-align: right; padding-right: 16px; user-select: none; }}
285.line-content {{ white-space: pre; }}
286.request-table {{ width: 100%; border-collapse: collapse; font-size: 13px; }}
287.request-table th, .request-table td {{ text-align: left; padding: 8px 12px; border-bottom: 1px solid #eee; }}
288.request-table th {{ background: #fafafa; width: 200px; color: #555; font-weight: 600; }}
289.request-table td {{ font-family: "Fira Code", monospace; word-break: break-all; }}
290.env-grid {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; font-size: 13px; }}
291.env-item {{ padding: 8px 12px; background: #fafafa; border-radius: 4px; }}
292.env-item strong {{ color: #555; display: inline-block; min-width: 120px; }}
293.footer {{ text-align: center; padding: 24px; color: #999; font-size: 12px; }}
294</style>
295</head>
296<body>
297<div class="header">
298 <h1>{title}</h1>
299 <div class="location">{file_display}:{error_line}</div>
300</div>
301<div class="container">
302"#
303 );
304
305 if config.show_stack && !error.stack.is_empty() {
307 html.push_str("<div class=\"section\">\n");
308 html.push_str(" <div class=\"section-title\">Stack frames (");
309 html.push_str(&error.stack.len().to_string());
310 html.push_str(")</div>\n");
311 html.push_str(" <div class=\"section-body\">\n");
312 for frame in &error.stack {
313 html.push_str(&render_stack_frame_html(frame));
314 }
315 html.push_str(" </div>\n</div>\n");
316 }
317
318 if config.show_request && !error.method.is_empty() {
320 html.push_str("<div class=\"section\">\n");
321 html.push_str(" <div class=\"section-title\">Request</div>\n");
322 html.push_str(" <div class=\"section-body\">\n");
323 html.push_str(" <table class=\"request-table\">\n");
324 html.push_str(&format!(
325 " <tr><th>Method</th><td>{}</td></tr>\n",
326 method_display
327 ));
328 html.push_str(&format!(
329 " <tr><th>URI</th><td>{}</td></tr>\n",
330 uri_display
331 ));
332 for (key, value) in &error.headers {
333 html.push_str(&format!(
334 " <tr><th>{}</th><td>{}</td></tr>\n",
335 html_escape(key),
336 html_escape(value)
337 ));
338 }
339 for (key, value) in &error.query_params {
340 html.push_str(&format!(
341 " <tr><th>Query: {}</th><td>{}</td></tr>\n",
342 html_escape(key),
343 html_escape(value)
344 ));
345 }
346 html.push_str(" </table>\n </div>\n</div>\n");
347 }
348
349 if config.show_environment {
351 html.push_str("<div class=\"section\">\n");
352 html.push_str(" <div class=\"section-title\">Environment</div>\n");
353 html.push_str(" <div class=\"section-body\">\n");
354 html.push_str(" <div class=\"env-grid\">\n");
355 html.push_str(&format!(
356 " <div class=\"env-item\"><strong>Rust version</strong> {}</div>\n",
357 env!("CARGO_PKG_VERSION")
358 ));
359 html.push_str(&format!(
360 " <div class=\"env-item\"><strong>PID</strong> {}</div>\n",
361 std::process::id()
362 ));
363 if let Ok(cwd) = std::env::current_dir() {
364 html.push_str(&format!(
365 " <div class=\"env-item\"><strong>Working dir</strong> {}</div>\n",
366 html_escape(&cwd.display().to_string())
367 ));
368 }
369 let now = chrono::Local::now();
370 html.push_str(&format!(
371 " <div class=\"env-item\"><strong>Time</strong> {}</div>\n",
372 html_escape(&now.format("%Y-%m-%d %H:%M:%S").to_string())
373 ));
374 html.push_str(" </div>\n </div>\n</div>\n");
375 }
376
377 html.push_str("</div>\n");
378 html.push_str(
379 "<div class=\"footer\">SZ-Rust Whoops-style Debugger — debug mode enabled</div>\n",
380 );
381 html.push_str("</body>\n</html>\n");
382
383 html
384}
385
386fn render_stack_frame_html(frame: &StackFrame) -> String {
388 let function = html_escape(&frame.function);
389 let location = html_escape(&format!("{}:{}", frame.file, frame.line));
390
391 let mut html = format!(
392 r#" <div class="stack-frame">
393 <div class="frame-header">
394 <span class="frame-function">{function}</span>
395 <span class="frame-location">{location}</span>
396 </div>
397"#
398 );
399
400 if !frame.source_lines.is_empty() {
401 html.push_str(" <div class=\"source-list\">\n");
402 for (line_num, content) in &frame.source_lines {
403 let is_error_line = *line_num == frame.line;
404 let line_class = if is_error_line { " error" } else { "" };
405 html.push_str(&format!(
406 " <div class=\"source-line{}\"><span class=\"line-num\">{}</span><span class=\"line-content\">{}</span></div>\n",
407 line_class,
408 line_num,
409 html_escape(content)
410 ));
411 }
412 html.push_str(" </div>\n");
413 }
414
415 html.push_str(" </div>\n");
416 html
417}
418
419pub fn render_production_html(status: StatusCode, message: &str) -> String {
421 let status_code = status.as_u16();
422 let status_text = html_escape(status.canonical_reason().unwrap_or("Error"));
423 let msg = html_escape(message);
424
425 format!(
426 r#"<!DOCTYPE html>
427<html lang="zh-CN">
428<head>
429<meta charset="UTF-8">
430<title>{status_code} {status_text}</title>
431<style>
432body {{ 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; }}
433.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; }}
434.error-code {{ font-size: 72px; font-weight: 700; color: #d23f31; line-height: 1; margin-bottom: 16px; }}
435.error-title {{ font-size: 20px; color: #555; margin-bottom: 8px; }}
436.error-message {{ font-size: 14px; color: #999; }}
437</style>
438</head>
439<body>
440<div class="error-box">
441 <div class="error-code">{status_code}</div>
442 <div class="error-title">{status_text}</div>
443 <div class="error-message">{msg}</div>
444</div>
445</body>
446</html>
447"#
448 )
449}
450
451pub fn debug_error_response(
465 status: StatusCode,
466 error: &DebugError,
467 config: &DebugPageConfig,
468) -> Response {
469 let html = if config.debug_mode {
470 render_debug_html(error, config)
471 } else {
472 let safe_message = status.canonical_reason().unwrap_or("Error");
474 render_production_html(status, safe_message)
475 };
476
477 (
478 status,
479 [(axum::http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
480 html,
481 )
482 .into_response()
483}
484
485pub fn from_panic(panic_info: &std::panic::PanicHookInfo<'_>) -> DebugError {
491 let message = panic_info
492 .payload()
493 .downcast_ref::<&str>()
494 .map(|s| s.to_string())
495 .or_else(|| panic_info.payload().downcast_ref::<String>().cloned())
496 .unwrap_or_else(|| "<unknown panic payload>".to_string());
497
498 let (file, line) = panic_info
499 .location()
500 .map(|loc| (loc.file().to_string(), loc.line() as usize))
501 .unwrap_or_else(|| ("<unknown>".to_string(), 0));
502
503 DebugError::new("panic", message, file, line)
504}
505
506fn html_escape(s: &str) -> String {
514 let mut escaped = String::with_capacity(s.len());
515 for ch in s.chars() {
516 match ch {
517 '&' => escaped.push_str("&"),
518 '<' => escaped.push_str("<"),
519 '>' => escaped.push_str(">"),
520 '"' => escaped.push_str("""),
521 '\'' => escaped.push_str("'"),
522 _ => escaped.push(ch),
523 }
524 }
525 escaped
526}
527
528fn sanitize_headers(headers: &HeaderMap) -> HashMap<String, String> {
533 const SENSITIVE_HEADERS: &[&str] = &[
534 "authorization",
535 "cookie",
536 "set-cookie",
537 "x-api-key",
538 "x-auth-token",
539 ];
540
541 let mut sanitized = HashMap::new();
542 for (name, value) in headers.iter() {
543 let name_str = name.as_str().to_lowercase();
544 let value_str = if SENSITIVE_HEADERS.contains(&name_str.as_str()) {
545 "<redacted>".to_string()
546 } else {
547 value.to_str().unwrap_or("<binary>").to_string()
548 };
549 sanitized.insert(name_str, value_str);
550 }
551 sanitized
552}
553
554#[cfg(test)]
559mod tests {
560 use super::*;
561 use axum::http::{HeaderValue, Method};
562
563 #[test]
568 fn test_html_escape_basic() {
569 assert_eq!(html_escape("hello"), "hello");
570 assert_eq!(html_escape("<script>"), "<script>");
571 assert_eq!(html_escape("\"quote\""), ""quote"");
572 assert_eq!(html_escape("'apos'"), "'apos'");
573 assert_eq!(html_escape("a & b"), "a & b");
574 }
575
576 #[test]
577 fn test_html_escape_empty() {
578 assert_eq!(html_escape(""), "");
579 }
580
581 #[test]
582 fn test_html_escape_xss_payload() {
583 let payload = "<script>alert('XSS')</script>";
584 let escaped = html_escape(payload);
585 assert!(!escaped.contains('<'));
586 assert!(!escaped.contains('>'));
587 assert!(escaped.contains("<script>"));
588 }
589
590 #[test]
595 fn test_stack_frame_load_source_snippet() {
596 let temp = tempfile::tempdir().unwrap();
597 let file_path = temp.path().join("test.rs");
598 std::fs::write(
599 &file_path,
600 "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\n",
601 )
602 .unwrap();
603
604 let mut frame = StackFrame::new(file_path.to_str().unwrap(), 5, "test_fn");
605 frame.load_source_snippet(2);
606
607 assert_eq!(frame.source_lines.len(), 5);
609 assert_eq!(frame.source_lines[0].0, 3);
610 assert_eq!(frame.source_lines[0].1, "line3");
611 assert_eq!(frame.source_lines[2].0, 5);
612 assert_eq!(frame.source_lines[2].1, "line5");
613 assert_eq!(frame.source_lines[4].0, 7);
614 assert_eq!(frame.source_lines[4].1, "line7");
615 }
616
617 #[test]
618 fn test_stack_frame_load_source_snippet_at_file_start() {
619 let temp = tempfile::tempdir().unwrap();
620 let file_path = temp.path().join("start.rs");
621 std::fs::write(&file_path, "line1\nline2\nline3\n").unwrap();
622
623 let mut frame = StackFrame::new(file_path.to_str().unwrap(), 1, "first");
624 frame.load_source_snippet(10);
625
626 assert_eq!(frame.source_lines.len(), 3);
628 assert_eq!(frame.source_lines[0].0, 1);
629 }
630
631 #[test]
632 fn test_stack_frame_load_source_snippet_nonexistent_file() {
633 let mut frame = StackFrame::new("/nonexistent/file.rs", 10, "missing");
634 frame.load_source_snippet(5);
635 assert!(frame.source_lines.is_empty());
636 }
637
638 #[test]
639 fn test_stack_frame_load_source_snippet_zero_line() {
640 let mut frame = StackFrame::new("test.rs", 0, "unknown");
641 frame.load_source_snippet(5);
642 assert!(frame.source_lines.is_empty());
643 }
644
645 #[test]
650 fn test_debug_page_config_default() {
651 let config = DebugPageConfig::default();
652 assert!(!config.debug_mode);
653 assert_eq!(config.source_context, 10);
654 }
655
656 #[test]
657 fn test_debug_page_config_development() {
658 let config = DebugPageConfig::development();
659 assert!(config.debug_mode);
660 assert!(config.show_stack);
661 assert!(config.show_request);
662 assert!(config.show_environment);
663 }
664
665 #[test]
666 fn test_debug_page_config_production() {
667 let config = DebugPageConfig::production();
668 assert!(!config.debug_mode);
669 assert!(!config.show_stack);
670 assert_eq!(config.source_context, 0);
671 }
672
673 #[test]
678 fn test_render_debug_html_contains_error_info() {
679 let error = DebugError::new("TestError", "test message", "test.rs", 42);
680 let config = DebugPageConfig::development();
681 let html = render_debug_html(&error, &config);
682
683 assert!(html.contains("TestError"));
684 assert!(html.contains("test message"));
685 assert!(html.contains("test.rs:42"));
686 assert!(html.contains("<!DOCTYPE html>"));
687 }
688
689 #[test]
690 fn test_render_debug_html_escapes_xss() {
691 let error = DebugError::new(
692 "<script>alert('xss')</script>",
693 "<img src=x onerror=alert(1)>",
694 "test.rs",
695 1,
696 );
697 let config = DebugPageConfig::development();
698 let html = render_debug_html(&error, &config);
699
700 assert!(!html.contains("<script>alert"));
702 assert!(html.contains("<script>"));
704 assert!(html.contains("<img"));
705 }
706
707 #[test]
708 fn test_render_debug_html_shows_stack() {
709 let error = DebugError::new("Err", "msg", "test.rs", 1)
710 .with_frame(StackFrame::new("frame1.rs", 10, "func1"))
711 .with_frame(StackFrame::new("frame2.rs", 20, "func2"));
712 let config = DebugPageConfig::development();
713 let html = render_debug_html(&error, &config);
714
715 assert!(html.contains("func1"));
716 assert!(html.contains("func2"));
717 assert!(html.contains("frame1.rs:10"));
718 assert!(html.contains("frame2.rs:20"));
719 assert!(html.contains("Stack frames (2)"));
720 }
721
722 #[test]
723 fn test_render_debug_html_hides_stack_when_disabled() {
724 let error = DebugError::new("Err", "msg", "test.rs", 1).with_frame(StackFrame::new(
725 "frame1.rs",
726 10,
727 "func1",
728 ));
729 let mut config = DebugPageConfig::development();
730 config.show_stack = false;
731 let html = render_debug_html(&error, &config);
732
733 assert!(!html.contains("func1"));
734 assert!(!html.contains("Stack frames"));
735 }
736
737 #[test]
738 fn test_render_debug_html_shows_request_info() {
739 let mut headers = HeaderMap::new();
740 headers.insert("x-custom", HeaderValue::from_static("value1"));
741 let mut query = HashMap::new();
742 query.insert("id".to_string(), "123".to_string());
743
744 let error = DebugError::new("Err", "msg", "test.rs", 1).with_request(
745 Method::GET.as_str(),
746 "/api/users",
747 headers,
748 query,
749 );
750 let config = DebugPageConfig::development();
751 let html = render_debug_html(&error, &config);
752
753 assert!(html.contains("GET"));
754 assert!(html.contains("/api/users"));
755 assert!(html.contains("x-custom"));
756 assert!(html.contains("value1"));
757 assert!(html.contains("Query: id"));
758 assert!(html.contains("123"));
759 }
760
761 #[test]
762 fn test_render_debug_html_redacts_sensitive_headers() {
763 let mut headers = HeaderMap::new();
764 headers.insert(
765 "authorization",
766 HeaderValue::from_static("Bearer secret123"),
767 );
768 headers.insert("cookie", HeaderValue::from_static("session=abc"));
769
770 let error = DebugError::new("Err", "msg", "test.rs", 1).with_request(
771 "POST",
772 "/api",
773 headers,
774 HashMap::new(),
775 );
776 let config = DebugPageConfig::development();
777 let html = render_debug_html(&error, &config);
778
779 assert!(!html.contains("secret123"));
781 assert!(!html.contains("session=abc"));
782 assert!(html.contains("<redacted>"));
784 }
785
786 #[test]
787 fn test_render_debug_html_shows_environment() {
788 let error = DebugError::new("Err", "msg", "test.rs", 1);
789 let config = DebugPageConfig::development();
790 let html = render_debug_html(&error, &config);
791
792 assert!(html.contains("PID"));
793 assert!(html.contains("Rust version"));
794 }
795
796 #[tokio::test]
797 async fn test_render_production_html_no_stack() {
798 let error = DebugError::new("SecretError", "internal db password=xxx", "secret.rs", 100)
799 .with_frame(StackFrame::new("frame.rs", 1, "secret_func"));
800 let config = DebugPageConfig::production();
801 let response = debug_error_response(StatusCode::INTERNAL_SERVER_ERROR, &error, &config);
804 use http_body_util::BodyExt;
806 let bytes = response.into_body().collect().await.unwrap().to_bytes();
807 let html = String::from_utf8(bytes.to_vec()).unwrap();
808
809 assert!(!html.contains("SecretError"));
811 assert!(!html.contains("secret.rs"));
812 assert!(!html.contains("secret_func"));
813 assert!(!html.contains("password=xxx"));
814 }
815
816 #[test]
821 fn test_render_production_html_basic() {
822 let html = render_production_html(StatusCode::INTERNAL_SERVER_ERROR, "Server Error");
823 assert!(html.contains("500"));
824 assert!(html.contains("Internal Server Error"));
825 assert!(html.contains("Server Error"));
826 assert!(html.contains("<!DOCTYPE html>"));
827 }
828
829 #[test]
830 fn test_render_production_html_escapes_message() {
831 let html = render_production_html(StatusCode::BAD_REQUEST, "<script>alert('xss')</script>");
832 assert!(!html.contains("<script>alert"));
833 assert!(html.contains("<script>"));
834 }
835
836 #[tokio::test]
841 async fn test_debug_error_response_debug_mode() {
842 let error = DebugError::new("TestError", "test", "test.rs", 1);
843 let config = DebugPageConfig::development();
844 let response = debug_error_response(StatusCode::INTERNAL_SERVER_ERROR, &error, &config);
845
846 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
847 assert_eq!(
848 response.headers().get("content-type").unwrap(),
849 "text/html; charset=utf-8"
850 );
851 }
852
853 #[tokio::test]
854 async fn test_debug_error_response_production_mode() {
855 let error = DebugError::new("TestError", "secret", "test.rs", 1);
856 let config = DebugPageConfig::production();
857 let response = debug_error_response(StatusCode::NOT_FOUND, &error, &config);
858
859 assert_eq!(response.status(), StatusCode::NOT_FOUND);
860 assert_eq!(
861 response.headers().get("content-type").unwrap(),
862 "text/html; charset=utf-8"
863 );
864 }
865
866 #[test]
871 fn test_sanitize_headers_redacts_authorization() {
872 let mut headers = HeaderMap::new();
873 headers.insert("authorization", HeaderValue::from_static("Bearer xyz"));
874 headers.insert("cookie", HeaderValue::from_static("sess=abc"));
875 headers.insert("x-custom", HeaderValue::from_static("safe"));
876
877 let sanitized = sanitize_headers(&headers);
878
879 assert_eq!(sanitized.get("authorization").unwrap(), "<redacted>");
880 assert_eq!(sanitized.get("cookie").unwrap(), "<redacted>");
881 assert_eq!(sanitized.get("x-custom").unwrap(), "safe");
882 }
883
884 #[test]
885 fn test_sanitize_headers_empty() {
886 let headers = HeaderMap::new();
887 let sanitized = sanitize_headers(&headers);
888 assert!(sanitized.is_empty());
889 }
890
891 #[test]
896 fn test_debug_error_builder_pattern() {
897 let error = DebugError::new("ErrType", "msg", "file.rs", 10)
898 .with_frame(StackFrame::new("frame.rs", 5, "fn1"));
899
900 assert_eq!(error.error_type, "ErrType");
901 assert_eq!(error.message, "msg");
902 assert_eq!(error.file, "file.rs");
903 assert_eq!(error.line, 10);
904 assert_eq!(error.stack.len(), 1);
905 assert_eq!(error.stack[0].function, "fn1");
906 }
907
908 #[test]
909 fn test_debug_error_with_source_snippet_loads_main_frame() {
910 let temp = tempfile::tempdir().unwrap();
911 let file_path = temp.path().join("main.rs");
912 std::fs::write(&file_path, "line1\nline2\nline3\nline4\nline5\n").unwrap();
913
914 let error =
915 DebugError::new("Err", "msg", file_path.to_str().unwrap(), 3).with_source_snippet(1);
916
917 assert!(!error.stack.is_empty());
919 assert_eq!(error.stack[0].line, 3);
920 assert!(!error.stack[0].source_lines.is_empty());
921 }
922}