Skip to main content

sz_rust_core/
debug_page.rs

1//! Whoops-style HTML 调试页面 — 对齐 PHP `whoops` 异常展示
2//!
3//! ## PHP 对齐
4//!
5//! ThinkPHP 6 默认集成 `whoops` 库,在开发环境渲染交互式错误页面:
6//! - 顶部红色标题栏(错误类型 + 消息)
7//! - 文件:行号(灰色路径,可点击打开 IDE)
8//! - 堆栈列表(可折叠,每帧显示文件:行号 + 函数名 + 源码片段)
9//! - 请求信息(method / uri / headers / query / body)
10//! - 环境信息(Rust 版本 / 进程 PID / 工作目录)
11//!
12//! 生产环境关闭调试页,返回简洁 JSON 或静态 HTML,避免泄露堆栈。
13//!
14//! ## 安全约束
15//!
16//! - 所有用户输入(错误消息、文件路径、请求头值)必须 HTML 转义,防止 XSS
17//! - 源码片段从文件读取,限制最多 21 行(前后 10 行 + 错误行),避免读取大文件
18//! - 调试页只在 `debug_mode = true` 时渲染,生产环境强制返回简洁页
19
20use axum::http::{HeaderMap, StatusCode};
21use axum::response::{IntoResponse, Response};
22use once_cell::sync::Lazy;
23use parking_lot::Mutex;
24use std::collections::HashMap;
25
26// ============================================================================
27// 调试错误信息
28// ============================================================================
29
30/// 单个堆栈帧
31///
32/// 对齐 PHP `whoops\Frame`:每帧包含文件、行号、函数名、源码片段。
33#[derive(Debug, Clone)]
34pub struct StackFrame {
35    /// 文件路径(绝对路径)
36    pub file: String,
37    /// 行号(1-based)
38    pub line: usize,
39    /// 函数名(如 `app::handler::create_user`)
40    pub function: String,
41    /// 源码片段(行号 → 源码行),由 [`DebugError::with_source_snippet`] 填充
42    pub source_lines: Vec<(usize, String)>,
43}
44
45impl StackFrame {
46    /// 创建新的堆栈帧(不含源码片段)
47    ///
48    /// # 参数
49    ///
50    /// - `file`:文件路径
51    /// - `line`:行号
52    /// - `function`:函数名
53    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    /// 从文件读取源码片段(错误行前后各 `context` 行,最多 `context * 2 + 1` 行)
63    ///
64    /// 读取失败(文件不存在/IO 错误)时静默忽略,`source_lines` 保持为空。
65    ///
66    /// # 参数
67    ///
68    /// - `context`:错误行前后的上下文行数(建议 10)
69    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, // 文件不可读(如内置函数、动态生成代码)
77        };
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/// 调试错误信息
93///
94/// 包含完整的错误上下文:消息、类型、堆栈、请求信息。
95/// 由 [`render_debug_html`] 渲染为 Whoops-style HTML。
96#[derive(Debug, Clone)]
97pub struct DebugError {
98    /// 错误类型名(如 `"panic"` / `"IoError"` / `"SqlError"`)
99    pub error_type: String,
100    /// 错误消息
101    pub message: String,
102    /// 错误发生的文件
103    pub file: String,
104    /// 错误发生的行号(1-based)
105    pub line: usize,
106    /// 堆栈帧列表(按调用顺序:最新帧在前)
107    pub stack: Vec<StackFrame>,
108    /// 请求方法
109    pub method: String,
110    /// 请求 URI
111    pub uri: String,
112    /// 请求头(已脱敏)
113    pub headers: HashMap<String, String>,
114    /// 请求查询参数
115    pub query_params: HashMap<String, String>,
116}
117
118impl DebugError {
119    /// 创建新的调试错误
120    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    /// 添加堆栈帧
140    pub fn with_frame(mut self, frame: StackFrame) -> Self {
141        self.stack.push(frame);
142        self
143    }
144
145    /// 设置请求信息
146    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    /// 为所有堆栈帧加载源码片段(包含错误位置本身)
161    ///
162    /// # 参数
163    ///
164    /// - `context`:错误行前后的上下文行数(建议 10)
165    pub fn with_source_snippet(mut self, context: usize) -> Self {
166        // 为错误位置加载源码
167        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            // 主错误信息也存为虚拟帧
171            if !main_frame.source_lines.is_empty() {
172                self.stack.insert(0, main_frame);
173            }
174        }
175
176        // 为所有堆栈帧加载源码
177        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// ============================================================================
187// 调试页配置
188// ============================================================================
189
190/// 调试页配置
191///
192/// 控制调试页的渲染行为:是否启用、源码上下文行数、是否显示堆栈。
193#[derive(Debug, Clone)]
194pub struct DebugPageConfig {
195    /// 是否启用调试模式(true 渲染 Whoops-style HTML,false 返回简洁错误页)
196    pub debug_mode: bool,
197    /// 源码上下文行数(错误行前后各 N 行)
198    pub source_context: usize,
199    /// 是否显示堆栈
200    pub show_stack: bool,
201    /// 是否显示请求信息
202    pub show_request: bool,
203    /// 是否显示环境信息
204    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    /// 创建开发环境配置(启用所有调试信息)
221    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    /// 创建生产环境配置(关闭所有调试信息,仅显示简洁错误页)
232    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
243// ============================================================================
244// HTML 渲染
245// ============================================================================
246
247/// 渲染 Whoops-style HTML 调试页
248///
249/// # 安全约束
250///
251/// - 所有用户输入经 `html_escape` 转义,防 XSS
252/// - HTML 内联 CSS(无外部依赖)
253/// - 源码片段限制最多 `context * 2 + 1` 行,避免读取大文件
254pub 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    // 显式绑定避免与内置 `line!` 宏冲突
260    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    // 堆栈
308    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    // 请求信息
321    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    // 环境信息
352    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
388/// 渲染单个堆栈帧的 HTML
389fn 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
421/// 渲染生产环境简洁错误页(不泄露堆栈)
422pub 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
453// ============================================================================
454// 响应构建
455// ============================================================================
456
457/// 根据调试配置构建错误响应
458///
459/// - `debug_mode = true`:返回 Whoops-style HTML(含堆栈/源码/请求信息)
460/// - `debug_mode = false`:返回简洁 HTML(仅状态码 + 标准描述,不泄露堆栈和原始消息)
461///
462/// # 安全约束
463///
464/// 生产模式下绝不显示 `error.message`(可能含敏感信息如 SQL/密码),
465/// 仅显示状态码的标准描述(如 `Internal Server Error`)。
466pub 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        // 生产模式:使用状态码的标准描述,不泄露原始错误消息
475        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
487/// 从 panic 信息构建调试错误
488///
489/// 用于 `std::panic::set_hook` 捕获 panic 并渲染调试页。
490///
491/// 注:`PanicHookInfo` 自 Rust 1.81.0 起稳定,本框架 MSRV 已提升至 1.81.0+。
492pub 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
508// ============================================================================
509// 辅助函数
510// ============================================================================
511
512/// HTML 转义(防 XSS)
513///
514/// 转义字符:`&` → `&amp;`、`<` → `&lt;`、`>` → `&gt;`、`"` → `&quot;`、`'` → `&#39;`
515fn 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("&amp;"),
520            '<' => escaped.push_str("&lt;"),
521            '>' => escaped.push_str("&gt;"),
522            '"' => escaped.push_str("&quot;"),
523            '\'' => escaped.push_str("&#39;"),
524            _ => escaped.push(ch),
525        }
526    }
527    escaped
528}
529
530/// 脱敏请求头(移除敏感字段值,仅保留键名)
531///
532/// 对齐 PHP `whoops` 的 `RequestDataFormatter`:Authorization / Cookie / Set-Cookie 等
533/// 敏感头仅显示 `<redacted>`。
534fn 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// ============================================================================
557// 数据库查询展示(对齐 PHP Whoops 的 SQL 查询面板)
558// ============================================================================
559
560/// 数据库查询调用位置 — 用于编辑器跳转
561///
562/// 对齐 PHP Whoops 的 `Frame`:记录 SQL 执行的调用位置,
563/// 可通过 [`editor_url`] 生成 IDE 跳转链接。
564#[derive(Debug, Clone, serde::Serialize)]
565pub struct DbQueryLocation {
566    /// 文件路径(绝对路径)
567    pub file: String,
568    /// 行号(1-based)
569    pub line: u32,
570    /// 函数名
571    pub function: Option<String>,
572}
573
574/// 数据库查询记录 — 对齐 PHP Whoops 的 SQL 查询展示
575///
576/// 包含 SQL 语句、绑定参数、执行时间、调用栈等信息,
577/// 由 [`DbQueryCollector`] 收集,[`render_db_queries_html`] 渲染。
578#[derive(Debug, Clone, serde::Serialize)]
579pub struct DbQuery {
580    /// SQL 语句
581    pub sql: String,
582    /// 绑定参数
583    pub bindings: Vec<serde_json::Value>,
584    /// 执行时间(毫秒)
585    pub duration_ms: u64,
586    /// 调用栈(文件:行号)
587    pub backtrace: Vec<DbQueryLocation>,
588    /// 查询类型(select/insert/update/delete)
589    pub query_type: String,
590    /// 数据库连接名
591    pub connection: String,
592}
593
594/// 数据库查询收集器 — 线程安全的查询记录存储
595///
596/// 对齐 PHP `DB::listen()` 全局监听器:在开发环境收集所有 SQL 查询,
597/// 用于调试页展示。生产环境通过 [`DbQueryCollector::set_enabled`] 关闭。
598#[derive(Debug, Default)]
599pub struct DbQueryCollector {
600    /// 查询记录列表
601    queries: Mutex<Vec<DbQuery>>,
602    /// 是否启用收集
603    enabled: Mutex<bool>,
604}
605
606impl DbQueryCollector {
607    /// 创建新的查询收集器(默认禁用)
608    pub fn new() -> Self {
609        Self::default()
610    }
611
612    /// 是否启用收集
613    pub fn enabled(&self) -> bool {
614        *self.enabled.lock()
615    }
616
617    /// 设置启用状态
618    pub fn set_enabled(&self, enabled: bool) {
619        *self.enabled.lock() = enabled;
620    }
621
622    /// 添加查询记录(仅在启用时生效)
623    pub fn add_query(&self, query: DbQuery) {
624        if self.enabled() {
625            self.queries.lock().push(query);
626        }
627    }
628
629    /// 获取所有查询的快照
630    pub fn queries(&self) -> Vec<DbQuery> {
631        self.queries.lock().clone()
632    }
633
634    /// 总执行时间(毫秒)
635    pub fn total_duration_ms(&self) -> u64 {
636        self.queries.lock().iter().map(|q| q.duration_ms).sum()
637    }
638
639    /// 清空记录
640    pub fn clear(&self) {
641        self.queries.lock().clear();
642    }
643
644    /// 查询数量
645    pub fn count(&self) -> usize {
646        self.queries.lock().len()
647    }
648}
649
650/// 全局数据库查询收集器(对齐 PHP `DB::listen()` 全局监听器)
651///
652/// 启动时默认禁用,业务层在开发环境调用
653/// `GLOBAL_DB_QUERY_COLLECTOR.set_enabled(true)` 开启收集。
654pub static GLOBAL_DB_QUERY_COLLECTOR: Lazy<DbQueryCollector> = Lazy::new(DbQueryCollector::new);
655
656// ============================================================================
657// 编辑器跳转
658// ============================================================================
659
660/// 编辑器类型 — 对齐 PHP Whoops 的 editor 配置
661///
662/// 用于 [`editor_url`] 生成 IDE 跳转链接,点击调试页中的文件位置可直接打开 IDE。
663#[derive(Debug, Clone, Copy, PartialEq, Eq)]
664pub enum Editor {
665    /// VS Code
666    VsCode,
667    /// PhpStorm
668    PhpStorm,
669    /// Sublime Text
670    Sublime,
671    /// Atom
672    Atom,
673    /// IDE 通用协议
674    Ide,
675}
676
677/// 生成编辑器跳转 URL
678///
679/// # 参数
680///
681/// - `editor`:编辑器类型
682/// - `file`:文件路径(绝对路径)
683/// - `line`:行号
684///
685/// # URL 格式
686///
687/// - [`Editor::VsCode`]:`vscode://file/{file}:{line}`
688/// - [`Editor::PhpStorm`]:`phpstorm://open?file={file}&line={line}`
689/// - [`Editor::Sublime`]:`subl://open?url=file://{file}&line={line}`
690/// - [`Editor::Atom`]:`atom://core/open/file?filename={file}&line={line}`
691/// - [`Editor::Ide`]:`idea://open?file={file}&line={line}`
692pub 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
702/// 渲染数据库查询列表为 HTML 片段
703///
704/// 返回可直接嵌入调试页的 HTML 片段。若 `queries` 为空,返回空字符串。
705///
706/// # 安全约束
707///
708/// - 所有用户输入(SQL、文件路径、参数值)经 `html_escape` 转义,防 XSS
709/// - 编辑器 URL 中的 `&` 转义为 `&amp;`(HTML 属性要求)
710pub 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
730/// 渲染单个数据库查询为 HTML 片段
731fn 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} &bull; {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    // 绑定参数
752    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    // 调用栈(含编辑器跳转链接)
765    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// ============================================================================
789// 测试
790// ============================================================================
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795    use axum::http::{HeaderValue, Method};
796
797    // --------------------------------------------------------------------
798    // html_escape
799    // --------------------------------------------------------------------
800
801    #[test]
802    fn test_html_escape_basic() {
803        assert_eq!(html_escape("hello"), "hello");
804        assert_eq!(html_escape("<script>"), "&lt;script&gt;");
805        assert_eq!(html_escape("\"quote\""), "&quot;quote&quot;");
806        assert_eq!(html_escape("'apos'"), "&#39;apos&#39;");
807        assert_eq!(html_escape("a & b"), "a &amp; 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("&lt;script&gt;"));
822    }
823
824    // --------------------------------------------------------------------
825    // StackFrame::load_source_snippet
826    // --------------------------------------------------------------------
827
828    #[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        // 应该包含 line 3-7(5-2=3, 5+2=7)
842        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        // line=1, context=10, 但文件只有 3 行
861        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    // --------------------------------------------------------------------
880    // DebugPageConfig
881    // --------------------------------------------------------------------
882
883    #[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    // --------------------------------------------------------------------
908    // render_debug_html
909    // --------------------------------------------------------------------
910
911    #[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        // 原始 <script> 不应出现
935        assert!(!html.contains("<script>alert"));
936        // 转义后应出现
937        assert!(html.contains("&lt;script&gt;"));
938        assert!(html.contains("&lt;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        // 敏感值不应出现
1014        assert!(!html.contains("secret123"));
1015        assert!(!html.contains("session=abc"));
1016        // 应显示 <redacted>
1017        assert!(html.contains("&lt;redacted&gt;"));
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        // 通过 debug_error_response 验证生产模式行为(render_debug_html 始终渲染完整页,
1036        // 由 debug_error_response 根据 debug_mode 选择渲染策略)
1037        let response = debug_error_response(StatusCode::INTERNAL_SERVER_ERROR, &error, &config);
1038        // 转 HTML 字符串验证内容
1039        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        // 生产模式应使用 production_html(不含堆栈/敏感信息)
1044        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    // --------------------------------------------------------------------
1051    // render_production_html
1052    // --------------------------------------------------------------------
1053
1054    #[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("&lt;script&gt;"));
1068    }
1069
1070    // --------------------------------------------------------------------
1071    // debug_error_response
1072    // --------------------------------------------------------------------
1073
1074    #[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    // --------------------------------------------------------------------
1101    // sanitize_headers
1102    // --------------------------------------------------------------------
1103
1104    #[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    // --------------------------------------------------------------------
1126    // DebugError
1127    // --------------------------------------------------------------------
1128
1129    #[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        // 主错误位置应作为虚拟帧插入 stack[0]
1152        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    // --------------------------------------------------------------------
1158    // DbQueryLocation / DbQuery 序列化
1159    // --------------------------------------------------------------------
1160
1161    #[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    // --------------------------------------------------------------------
1199    // DbQueryCollector
1200    // --------------------------------------------------------------------
1201
1202    #[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        // 禁用后 add_query 应为 no-op
1228        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    // --------------------------------------------------------------------
1317    // editor_url
1318    // --------------------------------------------------------------------
1319
1320    #[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    // --------------------------------------------------------------------
1354    // render_db_queries_html
1355    // --------------------------------------------------------------------
1356
1357    #[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        // 应包含编辑器跳转链接
1406        assert!(html.contains("href=\""));
1407        assert!(html.contains("vscode://file//app/src/main.rs:42"));
1408    }
1409
1410    // --------------------------------------------------------------------
1411    // GLOBAL_DB_QUERY_COLLECTOR
1412    // --------------------------------------------------------------------
1413
1414    #[test]
1415    fn test_global_db_query_collector() {
1416        // 保存原始状态
1417        let was_enabled = GLOBAL_DB_QUERY_COLLECTOR.enabled();
1418
1419        // 清空并验证
1420        GLOBAL_DB_QUERY_COLLECTOR.clear();
1421        assert_eq!(GLOBAL_DB_QUERY_COLLECTOR.count(), 0);
1422
1423        // 启用并添加查询
1424        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        // 清理:恢复原始状态
1436        GLOBAL_DB_QUERY_COLLECTOR.clear();
1437        GLOBAL_DB_QUERY_COLLECTOR.set_enabled(was_enabled);
1438    }
1439}