修复: read_file 可靠性 + obscura 渲染(压缩豁免 + UTF-16 闭环 + stdout 救回)
read_file 压缩豁免(AI 定向读代码不折叠)+ UTF-16 BOM 闭环(read_file/read_symbol/patch_file); obscura 非零退出码救回 stdout + fetch markdown 空壳 fallback scrape --eval + 探测日志。
This commit is contained in:
@@ -380,8 +380,16 @@ pub fn should_summarize_tool_result(
|
|||||||
/// - 错误行(在头尾区间外的)额外插入,标注位置。
|
/// - 错误行(在头尾区间外的)额外插入,标注位置。
|
||||||
/// - 中间大段省略为 `... (省略 N 行) ...`。
|
/// - 中间大段省略为 `... (省略 N 行) ...`。
|
||||||
///
|
///
|
||||||
/// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。
|
/// `tool_name` 参与内容判断:`read_file` 是 AI 定向读代码的工具,压缩其 content
|
||||||
|
/// 等于直接阉割 AI 的代码分析能力(实测 limit=100 读 28KB,被折叠为首尾各 5 行),
|
||||||
|
/// 故对 `read_file` 一律豁免压缩(原样返回)。其余工具仍走压缩。
|
||||||
|
/// 空内容一律返回空字符串。
|
||||||
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||||
|
// read_file 豁免:AI 定向读代码,压缩 content 违背用户/AI 意图。
|
||||||
|
// read_file handler 自带 limit 硬上限 2000 行(file.rs:156/168),无爆 prompt 风险。
|
||||||
|
if tool_name == "read_file" {
|
||||||
|
return content.to_string();
|
||||||
|
}
|
||||||
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
|
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
|
||||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) {
|
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) {
|
||||||
if let Some(obj) = val.as_object_mut() {
|
if let Some(obj) = val.as_object_mut() {
|
||||||
@@ -1120,11 +1128,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn extract_key_info_single_huge_line_no_newline_compressed() {
|
fn extract_key_info_single_huge_line_no_newline_compressed() {
|
||||||
// 单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
|
// 单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
|
||||||
|
// 注:read_file 已豁免压缩(BUG-260801),此处用 run_command 验证通用压缩路径。
|
||||||
let content = "x".repeat(50_000);
|
let content = "x".repeat(50_000);
|
||||||
let result = extract_key_info(&content, "read_file");
|
let result = extract_key_info(&content, "run_command");
|
||||||
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
|
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
|
||||||
assert!(result.contains("已压缩"), "应含压缩标记");
|
assert!(result.contains("已压缩"), "应含压缩标记");
|
||||||
assert!(result.starts_with("[工具 read_file"), "应以工具名开头");
|
assert!(result.starts_with("[工具 run_command"), "应以工具名开头");
|
||||||
assert!(result.contains("原始 50000 字符"), "应报告原始字符数");
|
assert!(result.contains("原始 50000 字符"), "应报告原始字符数");
|
||||||
assert!(result.contains("(截断)"), "应含截断标记");
|
assert!(result.contains("(截断)"), "应含截断标记");
|
||||||
}
|
}
|
||||||
@@ -1133,9 +1142,10 @@ mod tests {
|
|||||||
fn extract_key_info_json_huge_string_field_truncated() {
|
fn extract_key_info_json_huge_string_field_truncated() {
|
||||||
// JSON 对象中大字符串字段(单行少行)逃逸压缩。
|
// JSON 对象中大字符串字段(单行少行)逃逸压缩。
|
||||||
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
|
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
|
||||||
|
// 注:read_file 已豁免压缩(BUG-260801),此处用 run_command 验证通用压缩路径。
|
||||||
let large = "z".repeat(10_000);
|
let large = "z".repeat(10_000);
|
||||||
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
|
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
|
||||||
let result = extract_key_info(&content, "read_file");
|
let result = extract_key_info(&content, "run_command");
|
||||||
assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len());
|
assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len());
|
||||||
assert!(result.contains("_truncated"), "应含 _truncated 标记");
|
assert!(result.contains("_truncated"), "应含 _truncated 标记");
|
||||||
assert!(result.contains("src/main.rs"), "应保留 path 字段");
|
assert!(result.contains("src/main.rs"), "应保留 path 字段");
|
||||||
@@ -1154,9 +1164,10 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn extract_key_info_eleven_lines_triggers_compression() {
|
fn extract_key_info_eleven_lines_triggers_compression() {
|
||||||
// 边界:行数 == 11(刚超 kept_boundary=10)→ 触发压缩,含标记
|
// 边界:行数 == 11(刚超 kept_boundary=10)→ 触发压缩,含标记
|
||||||
|
// 注:read_file 已豁免压缩(BUG-260801),此处用 run_command 验证通用压缩路径。
|
||||||
let lines: Vec<String> = (1..=11).map(|i| format!("line {}", i)).collect();
|
let lines: Vec<String> = (1..=11).map(|i| format!("line {}", i)).collect();
|
||||||
let content = lines.join("\n");
|
let content = lines.join("\n");
|
||||||
let result = extract_key_info(&content, "read_file");
|
let result = extract_key_info(&content, "run_command");
|
||||||
assert!(result.contains("已压缩"), "11 行应触发压缩");
|
assert!(result.contains("已压缩"), "11 行应触发压缩");
|
||||||
assert!(result.contains("line 1"), "保留首行");
|
assert!(result.contains("line 1"), "保留首行");
|
||||||
assert!(result.contains("line 11"), "保留末行");
|
assert!(result.contains("line 11"), "保留末行");
|
||||||
@@ -1167,6 +1178,7 @@ mod tests {
|
|||||||
// 边界:错误行恰在头部区间内(idx < head_end)→ 不重复插入(头部已含)
|
// 边界:错误行恰在头部区间内(idx < head_end)→ 不重复插入(头部已含)
|
||||||
// 错误行在尾部区间内(idx >= tail_start)→ 不重复插入(尾部已含)
|
// 错误行在尾部区间内(idx >= tail_start)→ 不重复插入(尾部已含)
|
||||||
// 错误行在中间区间 → 标注 [行 N] 插入
|
// 错误行在中间区间 → 标注 [行 N] 插入
|
||||||
|
// 注:read_file 已豁免压缩(BUG-260801),此处用 run_command 验证通用压缩路径。
|
||||||
let mut lines: Vec<String> = (1..=20).map(|i| format!("norm {}", i)).collect();
|
let mut lines: Vec<String> = (1..=20).map(|i| format!("norm {}", i)).collect();
|
||||||
// idx=2(头部区间 [0,5))错误行 → 头部已含,不在 error_lines(扫描跳过 head/tail)
|
// idx=2(头部区间 [0,5))错误行 → 头部已含,不在 error_lines(扫描跳过 head/tail)
|
||||||
lines[2] = "error in head zone".to_string();
|
lines[2] = "error in head zone".to_string();
|
||||||
@@ -1175,7 +1187,7 @@ mod tests {
|
|||||||
// idx=10(中间)错误行 → 标注插入
|
// idx=10(中间)错误行 → 标注插入
|
||||||
lines[10] = "error in middle".to_string();
|
lines[10] = "error in middle".to_string();
|
||||||
let content = lines.join("\n");
|
let content = lines.join("\n");
|
||||||
let result = extract_key_info(&content, "read_file");
|
let result = extract_key_info(&content, "run_command");
|
||||||
// 中间错误行被标注插入(原始行号 11)
|
// 中间错误行被标注插入(原始行号 11)
|
||||||
assert!(result.contains("[行 11] error in middle"), "中间错误行应标注插入: {}", result);
|
assert!(result.contains("[行 11] error in middle"), "中间错误行应标注插入: {}", result);
|
||||||
// 头/尾错误行原样保留(无 [行 N] 标注)
|
// 头/尾错误行原样保留(无 [行 N] 标注)
|
||||||
@@ -1327,4 +1339,74 @@ mod tests {
|
|||||||
assert_eq!(extract_pending_tc_id("文件内容"), None);
|
assert_eq!(extract_pending_tc_id("文件内容"), None);
|
||||||
assert_eq!(extract_pending_tc_id(""), None);
|
assert_eq!(extract_pending_tc_id(""), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── read_file 豁免压缩(BUG-260801: AI 定向读代码不应被折叠) ──
|
||||||
|
//
|
||||||
|
// 根因:extract_key_info 的 JSON 分支对 content 字段(文件内容)行数 > 10 即折叠中间为
|
||||||
|
// "(压缩中间内容)"。read_file limit=100 读 28KB(100 行)→ 触发 → AI 只拿到首尾各 5 行,
|
||||||
|
// 代码分析被阉割。修法:tool_name=="read_file" 一律原样返回(豁免)。
|
||||||
|
// read_file handler 自带 limit 硬上限 2000 行(file.rs),无爆 prompt 风险。
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_key_info_read_file_exempt_from_compression() {
|
||||||
|
// read_file 返回 JSON,content 字段 100 行(>10 行阈值)→ 其他工具会折叠,
|
||||||
|
// read_file 应豁免:原样返回,无 "(压缩中间内容)" / "_truncated"。
|
||||||
|
let content_str: String = (1..=100).map(|i| format!("line {}", i)).collect::<Vec<_>>().join("\n");
|
||||||
|
// 模拟 read_file 真实返回结构
|
||||||
|
let content = serde_json::json!({
|
||||||
|
"path": "/some/file.rs",
|
||||||
|
"content": content_str,
|
||||||
|
"lines": 100,
|
||||||
|
"returned_lines": 100
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let result = extract_key_info(&content, "read_file");
|
||||||
|
assert_eq!(result, content, "read_file 大结果应原样返回(豁免压缩)");
|
||||||
|
assert!(!result.contains("压缩中间内容"), "read_file 不应折叠中间");
|
||||||
|
assert!(!result.contains("_truncated"), "read_file 不应打 _truncated 标记");
|
||||||
|
// content 字段 100 行完整保留
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||||
|
assert_eq!(parsed["content"].as_str().unwrap().lines().count(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_key_info_other_tools_still_compressed_when_large() {
|
||||||
|
// 对照:同样 100 行 content,run_command 应仍被压缩(中间折叠)。
|
||||||
|
let content_str: Vec<String> = (1..=100).map(|i| format!("line {}", i)).collect();
|
||||||
|
let content = serde_json::json!({
|
||||||
|
"stdout": content_str.join("\n"),
|
||||||
|
"exit_code": 0
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let result = extract_key_info(&content, "run_command");
|
||||||
|
assert!(
|
||||||
|
result.contains("压缩中间内容"),
|
||||||
|
"run_command 大结果仍应压缩: {}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
assert!(result.contains("_truncated"), "run_command 应打 _truncated 标记");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_key_info_read_file_short_content_unchanged() {
|
||||||
|
// read_file 小结果同样原样(豁免对大小一致生效)。
|
||||||
|
let content = serde_json::json!({
|
||||||
|
"path": "/x.rs",
|
||||||
|
"content": "only one line",
|
||||||
|
"lines": 1
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
let result = extract_key_info(&content, "read_file");
|
||||||
|
assert_eq!(result, content, "read_file 小结果原样返回");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_key_info_read_file_non_json_plaintext_exempt() {
|
||||||
|
// read_file 纯文本大结果(理论上 read_file 总返回 JSON,但兜底:非 JSON 也豁免)。
|
||||||
|
let lines: Vec<String> = (1..=50).map(|i| format!("line {}", i)).collect();
|
||||||
|
let content = lines.join("\n");
|
||||||
|
let result = extract_key_info(&content, "read_file");
|
||||||
|
assert_eq!(result, content, "read_file 纯文本应豁免压缩");
|
||||||
|
assert!(!result.contains("输出已压缩"), "read_file 纯文本不应压缩");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ const OBSCURA_TIMEOUT_SECS: u64 = 45;
|
|||||||
/// obscura 未安装时的引导提示。回退静态管线后通过返回 JSON 的 `hint` 字段透传给 LLM/用户。
|
/// obscura 未安装时的引导提示。回退静态管线后通过返回 JSON 的 `hint` 字段透传给 LLM/用户。
|
||||||
const OBSCURA_HINT: &str = "obscura 未安装,已用静态模式。装 obscura(Rust 无头浏览器,自带 V8)可增强 JS 渲染文档(SPA/反爬):见 github.com/h4ckf0r0day/obscura";
|
const OBSCURA_HINT: &str = "obscura 未安装,已用静态模式。装 obscura(Rust 无头浏览器,自带 V8)可增强 JS 渲染文档(SPA/反爬):见 github.com/h4ckf0r0day/obscura";
|
||||||
|
|
||||||
|
/// 二进制响应(含 \0)拒绝错误消息。引导式:明示改用 download_file 工具下载文件,
|
||||||
|
/// 让 AI 看到错误即知换工具(治会话 01f05167:fetch_url 拒二进制 + run_command curl 失败 → 卡死)。
|
||||||
|
const BINARY_REJECT_MSG: &str = "该 URL 返回二进制内容(图片/压缩包/文件等),fetch_url 只读 HTML 文档;\
|
||||||
|
如需下载文件请改用 download_file 工具(支持任意文件类型,含图片/二进制/文档)";
|
||||||
|
|
||||||
/// fetch_url 工具 handler 入口(供 tools/fetch_url.rs register 调用)。
|
/// fetch_url 工具 handler 入口(供 tools/fetch_url.rs register 调用)。
|
||||||
///
|
///
|
||||||
/// 参数:
|
/// 参数:
|
||||||
@@ -192,9 +197,12 @@ async fn fetch_static(
|
|||||||
total_bytes, MAX_HTML_BYTES
|
total_bytes, MAX_HTML_BYTES
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 含 \0 → 二进制(非 HTML),直接拒(PDF/图片等走专用工具,fetch_url 只处理文本网页)
|
// 含 \0 → 二进制(非 HTML),直接拒(PDF/图片等走专用工具,fetch_url 只处理文本网页)。
|
||||||
|
// 错误文案引导式:明示改用 download_file(支持任意文件类型,图片/压缩包/二进制均可下载),
|
||||||
|
// 让 AI 看到错误即知换工具,避免卡死在 fetch_url / run_command curl 死循环(实测会话 01f05167)。
|
||||||
|
// 错误消息抽为 const 便于单元测试断言(无需真实网络/含 \0 响应即可验证引导文案)。
|
||||||
if bytes.contains(&0u8) {
|
if bytes.contains(&0u8) {
|
||||||
anyhow::bail!("响应为二进制(非 HTML 文本),fetch_url 仅处理网页文档");
|
anyhow::bail!("{}", BINARY_REJECT_MSG);
|
||||||
}
|
}
|
||||||
let html = String::from_utf8_lossy(&bytes).into_owned();
|
let html = String::from_utf8_lossy(&bytes).into_owned();
|
||||||
|
|
||||||
@@ -236,7 +244,16 @@ async fn fetch_static(
|
|||||||
/// 流程:检测 obscura 在 PATH/已知路径 → spawn `obscura fetch --dump markdown <URL>`(带超时)
|
/// 流程:检测 obscura 在 PATH/已知路径 → spawn `obscura fetch --dump markdown <URL>`(带超时)
|
||||||
/// → 取 stdout → 截断 → 返回与静态管线同构的 JSON(render_mode=obscura)。
|
/// → 取 stdout → 截断 → 返回与静态管线同构的 JSON(render_mode=obscura)。
|
||||||
///
|
///
|
||||||
/// 任何环节失败(未装 / spawn 失败 / 非零退出 / 超时)都不向上抛,而是回退静态管线:
|
/// ## 两次尝试的鲁棒性(缺陷 3+4)
|
||||||
|
///
|
||||||
|
/// - **缺陷 3**:obscura 非零退出码时不直接回退——先看 stdout 是否仍有实质内容(V8 watchdog
|
||||||
|
/// kill 时 exit≠0 但 stdout 可能已加载大部分内容),有则用 partial 标记,无才回退。
|
||||||
|
/// - **缺陷 4**:`fetch --dump markdown` 对 JS 重的 SPA(如 Next.js hydration)可能输出空壳
|
||||||
|
/// (watchdog kill 在 dump 前)。此时自动 fallback 再试 `scrape --eval "document.body.innerText"`
|
||||||
|
/// ——后者走不同管线(innerText 纯文本,不等 JS 全执行完),对 SPA 更鲁棒。
|
||||||
|
/// 两次都空壳/无实质内容才回退静态管线。
|
||||||
|
///
|
||||||
|
/// 任何环节最终失败(未装 / spawn 失败 / 两次都超时 / 都空壳)都不向上抛,而是回退静态管线:
|
||||||
/// 内部调 fetch_static 拿结果,塞 hint 字段提示装 obscura。这样 render=true 在任何环境
|
/// 内部调 fetch_static 拿结果,塞 hint 字段提示装 obscura。这样 render=true 在任何环境
|
||||||
/// 都不会因 obscura 缺失/故障而让工具整体失败(引导式安装的核心保证)。
|
/// 都不会因 obscura 缺失/故障而让工具整体失败(引导式安装的核心保证)。
|
||||||
async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<Value> {
|
async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<Value> {
|
||||||
@@ -255,26 +272,25 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── spawn obscura(带进程级超时) ──
|
// ── 第 1 次尝试:fetch --dump markdown(输出结构 markdown,优先) ──
|
||||||
// obscura fetch --dump markdown <URL>:obscura 内部管 page load timeout(默认 30s),
|
// obscura 内部管 page load timeout(默认 30s),我们外加 tokio timeout 兜底 V8 启动/JIT
|
||||||
// 我们外加 tokio timeout 兜底 V8 启动/JIT 卡死等 obscura 自身超时管不到的场景。
|
// 卡死等 obscura 自身超时管不到的场景。
|
||||||
let mut cmd = tokio::process::Command::new(&obscura_path);
|
match run_obscura(&obscura_path, &["fetch", "--dump", "markdown", url_raw]).await {
|
||||||
cmd.args(["fetch", "--dump", "markdown", url_raw])
|
ObscuraRunResult::Markdown(md) => {
|
||||||
.stdout(std::process::Stdio::piped())
|
return Ok(build_obscura_json(url_raw, &md, max_length, started, "obscura", None));
|
||||||
.stderr(std::process::Stdio::piped());
|
|
||||||
// CREATE_NO_WINDOW:防 obscura spawn 弹控制台窗口闪烁(对齐 shell.rs build_command)。
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
use std::os::windows::process::CommandExt;
|
|
||||||
cmd.creation_flags(0x0800_0000);
|
|
||||||
}
|
}
|
||||||
|
ObscuraRunResult::Partial(md) => {
|
||||||
let output = match tokio::time::timeout(
|
// 缺陷 3:非零退出码但 stdout 有实质内容(watchdog 截断)→ 用 partial 结果,不回退。
|
||||||
Duration::from_secs(OBSCURA_TIMEOUT_SECS),
|
return Ok(build_obscura_json(
|
||||||
cmd.output(),
|
url_raw,
|
||||||
).await {
|
&md,
|
||||||
Ok(Ok(o)) => o,
|
max_length,
|
||||||
Ok(Err(e)) => {
|
started,
|
||||||
|
"obscura_partial",
|
||||||
|
Some("obscura 非零退出(可能 watchdog 截断),已用部分渲染结果"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ObscuraRunResult::SpawnFailed(e) => {
|
||||||
tracing::warn!("obscura spawn 失败({}),回退静态", e);
|
tracing::warn!("obscura spawn 失败({}),回退静态", e);
|
||||||
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
|
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
|
||||||
fallback["render_mode"] = json!("obscura_failed");
|
fallback["render_mode"] = json!("obscura_failed");
|
||||||
@@ -282,44 +298,198 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<
|
|||||||
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
||||||
return Ok(fallback);
|
return Ok(fallback);
|
||||||
}
|
}
|
||||||
Err(_) => {
|
ObscuraRunResult::Timeout => {
|
||||||
tracing::warn!("obscura 超时({}s),回退静态", OBSCURA_TIMEOUT_SECS);
|
tracing::warn!("obscura fetch 超时({}s),改试 scrape --eval", OBSCURA_TIMEOUT_SECS);
|
||||||
|
// 超时也走 scrape --eval 兜底(scraper 管线不同,可能更快返回)。
|
||||||
|
}
|
||||||
|
ObscuraRunResult::EmptyShell => {
|
||||||
|
// 缺陷 4:fetch markdown 空壳 → fallback scrape --eval。
|
||||||
|
tracing::info!("obscura fetch --dump markdown 空壳/无实质内容,改用 scrape --eval(innerText)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 第 2 次尝试:scrape --eval(输出 innerText 纯文本,对 SPA 鲁棒,缺陷 4) ──
|
||||||
|
match run_obscura(
|
||||||
|
&obscura_path,
|
||||||
|
&["scrape", "--eval", "document.body.innerText", url_raw],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
ObscuraRunResult::Markdown(md) | ObscuraRunResult::Partial(md) => {
|
||||||
|
// scrape --eval 拿到内容:innerText 纯文本无 markdown 结构,但能拿到正文。
|
||||||
|
return Ok(build_obscura_json(
|
||||||
|
url_raw,
|
||||||
|
&md,
|
||||||
|
max_length,
|
||||||
|
started,
|
||||||
|
"obscura_scrape_eval",
|
||||||
|
Some("obscura fetch markdown 空壳,改用 innerText 纯文本"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ObscuraRunResult::SpawnFailed(e) => {
|
||||||
|
tracing::warn!("obscura scrape spawn 失败({}),回退静态", e);
|
||||||
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
|
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
|
||||||
fallback["render_mode"] = json!("obscura_failed");
|
fallback["render_mode"] = json!("obscura_failed");
|
||||||
fallback["hint"] = json!(format!("obscura 渲染超时({}s),已用静态模式。{}", OBSCURA_TIMEOUT_SECS, OBSCURA_HINT));
|
fallback["hint"] = json!(format!("obscura 启动失败({}),已用静态模式。{}", e, OBSCURA_HINT));
|
||||||
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
||||||
return Ok(fallback);
|
return Ok(fallback);
|
||||||
}
|
}
|
||||||
};
|
ObscuraRunResult::Timeout => {
|
||||||
|
tracing::warn!("obscura scrape --eval 也超时({}s),回退静态", OBSCURA_TIMEOUT_SECS);
|
||||||
|
}
|
||||||
|
ObscuraRunResult::EmptyShell => {
|
||||||
|
tracing::warn!("obscura scrape --eval 也无实质内容,回退静态");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !output.status.success() {
|
// ── 两次都失败/空壳:回退静态管线 ──
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
tracing::warn!("obscura 非零退出({}),stderr: {}", output.status, stderr.trim());
|
|
||||||
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
|
let mut fallback = fetch_static(url_raw, max_length, DEFAULT_TIMEOUT_SECS, &Value::Null).await?;
|
||||||
fallback["render_mode"] = json!("static_fallback");
|
fallback["render_mode"] = json!("static_fallback");
|
||||||
fallback["hint"] = json!(format!("obscura 渲染失败(exit {}),已用静态模式。{}", output.status, OBSCURA_HINT));
|
fallback["hint"] = json!(format!(
|
||||||
|
"obscura 渲染失败(fetch markdown 与 scrape --eval 均无实质内容),已用静态模式。{}",
|
||||||
|
OBSCURA_HINT
|
||||||
|
));
|
||||||
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
fallback["elapsed_ms"] = json!(started.elapsed().as_millis() as u64);
|
||||||
return Ok(fallback);
|
Ok(fallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 取 stdout(已是 markdown) ──
|
/// obscura 单次执行结果(供 fetch_with_obscura 判定 partial/空壳/失败)。
|
||||||
let mut markdown = String::from_utf8_lossy(&output.stdout).into_owned();
|
///
|
||||||
// obscura 偶发在 markdown 前后带空白/日志行,trim 首尾;内部仍走 cleanup 折叠空行。
|
/// - `Markdown`:退出码 0 且 stdout 有实质内容(正常成功)。
|
||||||
markdown = cleanup_markdown(markdown.trim());
|
/// - `Partial`:非零退出码但 stdout 仍有实质内容(缺陷 3:watchdog kill 截断,内容已部分加载)。
|
||||||
let html_bytes_est = markdown.len(); // obscura 不给原始 HTML 字节数,用 markdown 长度近似(仅信息字段)
|
/// - `EmptyShell`:stdout 无实质内容(空 / 纯框架标签 / 极短)。caller 应尝试别的管线或回退。
|
||||||
|
/// - `Timeout`:超过 OBSCURA_TIMEOUT_SECS 被杀。
|
||||||
|
/// - `SpawnFailed`:spawn 本身失败(obscura 二进制损坏等)。
|
||||||
|
enum ObscuraRunResult {
|
||||||
|
Markdown(String),
|
||||||
|
Partial(String),
|
||||||
|
EmptyShell,
|
||||||
|
Timeout,
|
||||||
|
SpawnFailed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// spawn obscura 带指定子命令参数,带进程级超时,返回 stdout 内容分类。
|
||||||
|
///
|
||||||
|
/// fetch --dump markdown 与 scrape --eval 共用此 helper(同样的 spawn 装配 + 超时 + 实质内容判定),
|
||||||
|
/// 保证两条管线鲁棒性一致、且都带 CREATE_NO_WINDOW(防弹窗)。
|
||||||
|
async fn run_obscura(obscura_path: &str, args: &[&str]) -> ObscuraRunResult {
|
||||||
|
let mut cmd = tokio::process::Command::new(obscura_path);
|
||||||
|
cmd.args(args)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped());
|
||||||
|
// CREATE_NO_WINDOW:防 obscura spawn 弹控制台窗口闪烁(对齐 shell.rs build_command)。
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
cmd.creation_flags(0x0800_0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = match tokio::time::timeout(
|
||||||
|
Duration::from_secs(OBSCURA_TIMEOUT_SECS),
|
||||||
|
cmd.output(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Ok(o)) => o,
|
||||||
|
Ok(Err(e)) => return ObscuraRunResult::SpawnFailed(format!("{}", e)),
|
||||||
|
Err(_) => return ObscuraRunResult::Timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
let success = output.status.success();
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
if !success {
|
||||||
|
// 保留原 :297 的 stderr warn 日志(诊断 obscura 非零退出原因)。
|
||||||
|
tracing::warn!("obscura 非零退出({}),stderr: {}", output.status, stderr.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// obscura 偶发在 stdout 前后带空白/日志行,trim 首尾;内部仍走 cleanup 折叠空行。
|
||||||
|
let markdown = cleanup_markdown(String::from_utf8_lossy(&output.stdout).trim());
|
||||||
|
|
||||||
|
if has_substantial_content(&markdown) {
|
||||||
|
if success {
|
||||||
|
ObscuraRunResult::Markdown(markdown)
|
||||||
|
} else {
|
||||||
|
// 缺陷 3:非零退出码但有实质内容(watchdog 截断)。
|
||||||
|
ObscuraRunResult::Partial(markdown)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ObscuraRunResult::EmptyShell
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判定 obscura stdout 是否有实质内容(缺陷 3+4 共用)。
|
||||||
|
///
|
||||||
|
/// 用于区分:watchdog kill 时 stdout 已加载大部分内容(可用 partial)vs SPA 空壳(无可读正文)。
|
||||||
|
/// 判定规则(层层剔除空壳形态):
|
||||||
|
/// 1. trim 后 <500 chars → 无实质内容(空/极短)。
|
||||||
|
/// 2. 从原文移除常见空壳骨架(`<div id=root></div>` 等 hydration 占位标签)。
|
||||||
|
/// 3. 从剩余文本移除占位词(loading / please enable javascript / 404 / not found 等)。
|
||||||
|
/// 4. 剔除后剩余「非空白字符」<200 → 判空壳(原文虽长但全是占位骨架/占位词)。
|
||||||
|
/// 真实正文即使在 watchdog 截断后,也会保留大量非占位正文 char → 通过。
|
||||||
|
fn has_substantial_content(md: &str) -> bool {
|
||||||
|
let trimmed = md.trim();
|
||||||
|
if trimmed.chars().count() < 500 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let lower = trimmed.to_lowercase();
|
||||||
|
// 第 2 步:移除空壳骨架标签(hydration 占位)。
|
||||||
|
let shell_markers = [
|
||||||
|
"<div id=\"root\"></div>",
|
||||||
|
"<div id=root></div>",
|
||||||
|
"<div id=\"app\"></div>",
|
||||||
|
"<div id=app></div>",
|
||||||
|
"<noscript>",
|
||||||
|
"</noscript>",
|
||||||
|
];
|
||||||
|
let mut stripped: String = lower.clone();
|
||||||
|
for marker in shell_markers {
|
||||||
|
stripped = stripped.replace(marker, "");
|
||||||
|
}
|
||||||
|
// 第 3 步:移除占位词(空壳站点常常只有这类文案)。
|
||||||
|
for placeholder in [
|
||||||
|
"loading",
|
||||||
|
"please wait",
|
||||||
|
"please enable javascript",
|
||||||
|
"enable javascript",
|
||||||
|
"enable js",
|
||||||
|
"javascript is required",
|
||||||
|
"404",
|
||||||
|
"not found",
|
||||||
|
"page not found",
|
||||||
|
] {
|
||||||
|
stripped = stripped.replace(placeholder, "");
|
||||||
|
}
|
||||||
|
// 第 4 步:剩余「有效字符」(非空白且非纯标点 .,!?:; 等)<200 → 判空壳。
|
||||||
|
// 标点不计入有效内容:Loading... 去掉 loading 后剩 ... 是空壳残留标点,非正文。
|
||||||
|
let non_ws_chars: usize = stripped
|
||||||
|
.chars()
|
||||||
|
.filter(|c| !c.is_whitespace() && !matches!(c, '.' | ',' | '!' | '?' | ':' | ';' | '-' | '_' | '|' | '*' | '#' | '<' | '>' | '/' | '=' | '"' | '\''))
|
||||||
|
.count();
|
||||||
|
non_ws_chars >= 200
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 组装 obscura 成功路径 JSON(三种 render_mode:obscura / obscura_partial / obscura_scrape_eval)。
|
||||||
|
///
|
||||||
|
/// 抽出共用:三种成功路径(正常/partial/scrape_eval)的 JSON 结构除 render_mode 与 hint
|
||||||
|
/// 外完全一致(同样从 markdown 启发式取 title、截断、算 length/html_bytes_est)。
|
||||||
|
fn build_obscura_json(
|
||||||
|
url_raw: &str,
|
||||||
|
markdown_raw: &str,
|
||||||
|
max_length: usize,
|
||||||
|
started: Instant,
|
||||||
|
render_mode: &str,
|
||||||
|
hint: Option<&str>,
|
||||||
|
) -> Value {
|
||||||
|
let html_bytes_est = markdown_raw.len(); // obscura 不给原始 HTML 字节数,用 markdown 长度近似(仅信息字段)
|
||||||
// 截断(按 char 边界,与静态一致)
|
// 截断(按 char 边界,与静态一致)
|
||||||
let (markdown, truncated) = truncate_chars(&markdown, max_length);
|
let (markdown, truncated) = truncate_chars(markdown_raw, max_length);
|
||||||
let elapsed_ms = started.elapsed().as_millis() as u64;
|
let elapsed_ms = started.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
// title:obscura --dump markdown 不单独给 title,从 markdown 首个 H1/首行启发式取;取不到则 null。
|
|
||||||
let title = extract_title_from_markdown(&markdown);
|
let title = extract_title_from_markdown(&markdown);
|
||||||
// scheme:obscura 路径下重新 validate_url 拿 scheme(已在入口校验过,这里仅取字段值,失败兜底 https)。
|
// scheme:obscura 路径下重新 validate_url 拿 scheme(已在入口校验过,这里仅取字段值,失败兜底 https)。
|
||||||
let scheme = crate::commands::ai::http::validate_url(url_raw)
|
let scheme = crate::commands::ai::http::validate_url(url_raw)
|
||||||
.map(|(s, _, _)| s)
|
.map(|(s, _, _)| s)
|
||||||
.unwrap_or_else(|_| "https".to_string());
|
.unwrap_or_else(|_| "https".to_string());
|
||||||
|
|
||||||
Ok(json!({
|
let mut v = json!({
|
||||||
"url": url_raw,
|
"url": url_raw,
|
||||||
"scheme": scheme,
|
"scheme": scheme,
|
||||||
"status": 200u16, // obscura 成功路径不暴露 HTTP status(已渲染),用 200 占位
|
"status": 200u16, // obscura 成功路径不暴露 HTTP status(已渲染),用 200 占位
|
||||||
@@ -329,14 +499,20 @@ async fn fetch_with_obscura(url_raw: &str, max_length: usize) -> anyhow::Result<
|
|||||||
"html_bytes": html_bytes_est,
|
"html_bytes": html_bytes_est,
|
||||||
"truncated": truncated,
|
"truncated": truncated,
|
||||||
"elapsed_ms": elapsed_ms,
|
"elapsed_ms": elapsed_ms,
|
||||||
"render_mode": "obscura",
|
"render_mode": render_mode,
|
||||||
}))
|
});
|
||||||
|
if let Some(h) = hint {
|
||||||
|
v["hint"] = json!(h);
|
||||||
|
}
|
||||||
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检测 obscura 是否已安装且可执行。优先级:PATH > 已知 npm-global 路径。
|
/// 检测 obscura 是否已安装且可执行。优先级:PATH > 已知 npm-global 路径。
|
||||||
///
|
///
|
||||||
/// 用 `obscura --version` 探活(比 `which` 跨平台:Windows 无 which,且 --version 能确认
|
/// 用 `obscura --version` 探活(比 `which` 跨平台:Windows 无 which,且 --version 能确认
|
||||||
/// 二进制可跑而非仅存在)。同步探活带 5s 超时防卡(对齐 module.rs::run_command 的 thread+channel 风格)。
|
/// 二进制可跑而非仅存在)。同步探活带 15s 超时防卡(对齐 module.rs::run_command 的 thread+channel 风格)。
|
||||||
|
/// 探活结果全程 tracing 日志(debug:每个 candidate 尝试/exit/超时;warn:全部失败列出试过的路径),
|
||||||
|
/// 让 Tauri 进程 PATH 与 PowerShell 不同导致的间歇性失败可诊断。
|
||||||
fn find_obscura() -> Option<String> {
|
fn find_obscura() -> Option<String> {
|
||||||
let candidates: &[&str] = &[
|
let candidates: &[&str] = &[
|
||||||
"obscura", // PATH 优先(用户 npm i -g 后即在 PATH)
|
"obscura", // PATH 优先(用户 npm i -g 后即在 PATH)
|
||||||
@@ -345,7 +521,9 @@ fn find_obscura() -> Option<String> {
|
|||||||
"D:\\NodeJS\\npm-global\\obscura.exe", // Windows 原生路径(tauri 在 Windows 跑)
|
"D:\\NodeJS\\npm-global\\obscura.exe", // Windows 原生路径(tauri 在 Windows 跑)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
let mut tried: Vec<&str> = Vec::with_capacity(candidates.len());
|
||||||
for cand in candidates {
|
for cand in candidates {
|
||||||
|
tracing::debug!("obscura 探测尝试 candidate: {}", cand);
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
let cand_owned = cand.to_string();
|
let cand_owned = cand.to_string();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
@@ -363,10 +541,25 @@ fn find_obscura() -> Option<String> {
|
|||||||
let _ = tx.send(out);
|
let _ = tx.send(out);
|
||||||
});
|
});
|
||||||
match rx.recv_timeout(Duration::from_secs(15)) {
|
match rx.recv_timeout(Duration::from_secs(15)) {
|
||||||
Ok(Ok(o)) if o.status.success() => return Some(cand.to_string()),
|
Ok(Ok(o)) if o.status.success() => {
|
||||||
_ => continue,
|
tracing::debug!("obscura 探测命中 candidate: {}", cand);
|
||||||
|
return Some(cand.to_string());
|
||||||
|
}
|
||||||
|
Ok(Ok(o)) => {
|
||||||
|
tracing::debug!("obscura 探测失败 candidate={}, exit={}", cand, o.status);
|
||||||
|
tried.push(cand);
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
tracing::debug!("obscura 探测 spawn 失败 candidate={}, err={}", cand, e);
|
||||||
|
tried.push(cand);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
tracing::debug!("obscura 探测超时(15s) candidate={}", cand);
|
||||||
|
tried.push(cand);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
tracing::warn!("obscura 所有 candidate 探测失败,试过: {:?}", tried);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,6 +673,21 @@ fn truncate_chars(md: &str, max: usize) -> (String, bool) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// ── 二进制拒绝错误文案(引导式:含 download_file 提示) ──
|
||||||
|
// 治会话 01f05167:fetch_url 拒二进制后 AI 不知换工具,卡死在 run_command curl。
|
||||||
|
// 错误消息必须明示 download_file,让 AI 看到错误即换工具。纯 const 断言零网络零依赖。
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_binary_reject_msg_guides_to_download_file() {
|
||||||
|
// 错误消息必须含 download_file 工具名(引导换工具的核心)
|
||||||
|
assert!(BINARY_REJECT_MSG.contains("download_file"));
|
||||||
|
// 必须明示 fetch_url 只读 HTML(让 AI 理解为何被拒)
|
||||||
|
assert!(BINARY_REJECT_MSG.contains("HTML"));
|
||||||
|
// 必须提及图片/二进制等关键词(匹配 AI 触发场景:用户给 .png URL)
|
||||||
|
assert!(BINARY_REJECT_MSG.contains("二进制"));
|
||||||
|
assert!(BINARY_REJECT_MSG.contains("图片"));
|
||||||
|
}
|
||||||
|
|
||||||
// ── extract_title ──
|
// ── extract_title ──
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -603,6 +811,150 @@ mod tests {
|
|||||||
assert!(!out.contains('\u{FFFD}'));
|
assert!(!out.contains('\u{FFFD}'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── has_substantial_content(缺陷 3+4 共用判定) ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substantial_content_real_markdown_passes() {
|
||||||
|
// 真实正文:600 chars,无空壳标记,应判有实质内容
|
||||||
|
let md = "# 异步编程指南\n\n这是一篇关于 Rust 异步编程的详细指南。\n".repeat(15);
|
||||||
|
assert!(has_substantial_content(&md));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substantial_content_short_returns_false() {
|
||||||
|
// <500 chars → 无实质内容
|
||||||
|
assert!(!has_substantial_content("short content only"));
|
||||||
|
assert!(!has_substantial_content(""));
|
||||||
|
assert!(!has_substantial_content(" \n\n "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substantial_content_empty_shell_root_div_rejected() {
|
||||||
|
// SPA hydration 空壳典型:仅 <div id="root"></div> + 大量空白填充到 >500 chars
|
||||||
|
let padding = " ".repeat(600);
|
||||||
|
let shell = format!("<div id=\"root\"></div>\n{}", padding);
|
||||||
|
assert!(!has_substantial_content(&shell));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substantial_content_empty_shell_app_div_rejected() {
|
||||||
|
// 仅 <div id="app"></div> 空壳
|
||||||
|
let padding = "\n".repeat(600);
|
||||||
|
let shell = format!("<div id=\"app\"></div>{}", padding);
|
||||||
|
assert!(!has_substantial_content(&shell));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substantial_content_loading_placeholder_rejected() {
|
||||||
|
// 纯 "Loading..." 占位符重复(去掉 "loading" 后仅剩标点/空白)→ 空壳
|
||||||
|
let placeholder = "Loading...\n".repeat(120);
|
||||||
|
assert!(!has_substantial_content(&placeholder));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substantial_content_enable_js_rejected() {
|
||||||
|
// 纯 "Please enable JavaScript" 占位(去掉占位词后仅剩空白)→ 空壳
|
||||||
|
let placeholder = "Please enable JavaScript\n".repeat(40);
|
||||||
|
assert!(!has_substantial_content(&placeholder));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_substantial_content_mixed_real_content_passes() {
|
||||||
|
// 含真实正文(loading 标题但有正文)→ 应判有实质内容
|
||||||
|
let md = "# Loading data\n\n".to_string()
|
||||||
|
+ &"这是真实的正文内容,描述了一篇技术文章的核心要点。".repeat(20);
|
||||||
|
assert!(has_substantial_content(&md));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ObscuraRunResult 分类逻辑(缺陷 3:partial 分支) ──
|
||||||
|
//
|
||||||
|
// 注:run_obscura 依赖真实 obscura 二进制 + 网络,此处不直接测 spawn,而是间接验证
|
||||||
|
// has_substantial_content 的判定正确性(它是 run_obscura 内 partial vs emptyshell 的核心)。
|
||||||
|
// 上面 has_substantial_content 测试已覆盖:有实质内容 → Markdown/Partial 分支可命中;
|
||||||
|
// 空壳 → EmptyShell 分支可命中。
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_obscura_partial_classification_via_substantial_check() {
|
||||||
|
// 模拟 watchdog 截断:obscura 非零退出但 stdout 已加载部分真实内容(>500 chars 正文)。
|
||||||
|
// run_obscura 会判 success=false + has_substantial_content=true → ObscuraRunResult::Partial。
|
||||||
|
let partial_stdout = "# 文章标题\n\n".to_string()
|
||||||
|
+ &"这是 watchdog 截断前已加载的部分正文内容,包含真实信息。".repeat(20);
|
||||||
|
// has_substantial_content 是 Partial 分支的唯一判据
|
||||||
|
assert!(has_substantial_content(&partial_stdout));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_obscura_emptyshell_classification_via_substantial_check() {
|
||||||
|
// 模拟 SPA 空壳:obscura 输出仅 hydration 占位 → EmptyShell → 触发 scrape --eval fallback
|
||||||
|
let empty_stdout = format!("<div id=\"root\"></div>\n{}", " ".repeat(600));
|
||||||
|
assert!(!has_substantial_content(&empty_stdout));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── build_obscura_json(render_mode 三态 + hint) ──
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_obscura_json_normal_mode_no_hint() {
|
||||||
|
let md = "# 标题\n\n正文内容".to_string();
|
||||||
|
let started = Instant::now();
|
||||||
|
let v = build_obscura_json("https://example.com/", &md, 1000, started, "obscura", None);
|
||||||
|
assert_eq!(v["render_mode"].as_str().unwrap(), "obscura");
|
||||||
|
assert_eq!(v["status"].as_u64().unwrap(), 200);
|
||||||
|
assert_eq!(v["url"].as_str().unwrap(), "https://example.com/");
|
||||||
|
assert_eq!(v["title"].as_str().unwrap(), "标题");
|
||||||
|
assert_eq!(v["scheme"].as_str().unwrap(), "https");
|
||||||
|
// hint 字段不存在
|
||||||
|
assert!(v.get("hint").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_obscura_json_partial_mode_with_hint() {
|
||||||
|
// 缺陷 3:obscura_partial render_mode + hint 说明 watchdog 截断
|
||||||
|
let md = "# 截断的标题\n\n".to_string() + &"部分正文。".repeat(50);
|
||||||
|
let started = Instant::now();
|
||||||
|
let v = build_obscura_json(
|
||||||
|
"https://example.com/",
|
||||||
|
&md,
|
||||||
|
10000,
|
||||||
|
started,
|
||||||
|
"obscura_partial",
|
||||||
|
Some("obscura 非零退出(可能 watchdog 截断),已用部分渲染结果"),
|
||||||
|
);
|
||||||
|
assert_eq!(v["render_mode"].as_str().unwrap(), "obscura_partial");
|
||||||
|
let hint = v["hint"].as_str().unwrap();
|
||||||
|
assert!(hint.contains("watchdog"));
|
||||||
|
assert!(hint.contains("部分渲染"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_obscura_json_scrape_eval_mode() {
|
||||||
|
// 缺陷 4:obscura_scrape_eval render_mode + hint 说明改用 innerText
|
||||||
|
let md = "这是 innerText 纯文本正文,无 markdown 结构。".repeat(20);
|
||||||
|
let started = Instant::now();
|
||||||
|
let v = build_obscura_json(
|
||||||
|
"https://example.com/",
|
||||||
|
&md,
|
||||||
|
10000,
|
||||||
|
started,
|
||||||
|
"obscura_scrape_eval",
|
||||||
|
Some("obscura fetch markdown 空壳,改用 innerText 纯文本"),
|
||||||
|
);
|
||||||
|
assert_eq!(v["render_mode"].as_str().unwrap(), "obscura_scrape_eval");
|
||||||
|
let hint = v["hint"].as_str().unwrap();
|
||||||
|
assert!(hint.contains("innerText"));
|
||||||
|
// innerText 无 markdown 结构 → extract_title_from_markdown 走首行 fallback
|
||||||
|
assert!(v["title"].as_str().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_obscura_json_truncation_applies() {
|
||||||
|
// build_obscura_json 内部应走 truncate_chars:超长 markdown 截断标记
|
||||||
|
let md: String = "abcdefghij".repeat(2000); // 20000 chars
|
||||||
|
let started = Instant::now();
|
||||||
|
let v = build_obscura_json("https://example.com/", &md, 1000, started, "obscura", None);
|
||||||
|
assert_eq!(v["truncated"], true);
|
||||||
|
assert!(v["markdown"].as_str().unwrap().contains("[markdown 已截断"));
|
||||||
|
}
|
||||||
|
|
||||||
// ── handler 参数边界 ──
|
// ── handler 参数边界 ──
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -38,11 +38,11 @@ use crate::state::AllowedDirs;
|
|||||||
|
|
||||||
// 复用 super::tool_registry 的常量/函数(单真相源,迁移后这些私有项已改 pub(crate))
|
// 复用 super::tool_registry 的常量/函数(单真相源,迁移后这些私有项已改 pub(crate))
|
||||||
use crate::commands::ai::tool_registry::{
|
use crate::commands::ai::tool_registry::{
|
||||||
apply_line_range, compile_glob_to_regex, compute_file_hash, generate_diff, grep_one_file,
|
apply_line_range, compile_glob_to_regex, compute_file_hash, decode_bytes_to_string,
|
||||||
grep_recursive, list_dir_recursive, probe_executable, rename_or_cross_volume_copy,
|
generate_diff, grep_one_file, grep_recursive, list_dir_recursive, probe_executable,
|
||||||
resolve_anchor_to_lines, resolve_workspace_path_with_allowed, search_files_recursive,
|
rename_or_cross_volume_copy, resolve_anchor_to_lines, resolve_workspace_path_with_allowed,
|
||||||
truncate_output, validate_path, DEFAULT_RUN_COMMAND_TIMEOUT_SECS, MAX_RUN_COMMAND_TIMEOUT_SECS,
|
search_files_recursive, truncate_output, validate_path, DEFAULT_RUN_COMMAND_TIMEOUT_SECS,
|
||||||
FILE_LOCKS, FileGrepHit,
|
MAX_RUN_COMMAND_TIMEOUT_SECS, FILE_LOCKS, FileGrepHit,
|
||||||
};
|
};
|
||||||
|
|
||||||
// run_command 依赖 df_execute::shell::{execute_streaming, ShellRequest, StreamKind} + std HashMap
|
// run_command 依赖 df_execute::shell::{execute_streaming, ShellRequest, StreamKind} + std HashMap
|
||||||
@@ -103,18 +103,25 @@ pub fn register(
|
|||||||
// TD-260621-04 闭环:返回 file_hash 供 patch_file.expected_hash 比对(防并发修改)。
|
// TD-260621-04 闭环:返回 file_hash 供 patch_file.expected_hash 比对(防并发修改)。
|
||||||
// 三返回点(二进制降级/search/默认分页)共用此值,文件未改时跨分页稳定。
|
// 三返回点(二进制降级/search/默认分页)共用此值,文件未改时跨分页稳定。
|
||||||
let file_hash = compute_file_hash(&metadata);
|
let file_hash = compute_file_hash(&metadata);
|
||||||
// 二进制/非 UTF-8 降级:read_to_string 对二进制硬失败,降级返 binary 标记而非错(防读二进制炸对话)
|
// P1 修复:原始字节读取 + UTF-16 LE/BE BOM 优先解码,治 PowerShell Out-File/> 产
|
||||||
let mut content = String::new();
|
// UTF-16 LE BOM 文件被 read_to_string 判 InvalidData(ASCII 高字节 0x00)→ 误判二进制拒读
|
||||||
if let Err(e) = file.read_to_string(&mut content).await {
|
// 致 run_command → 文件 → read_file 链路在 Windows 断裂。真二进制(无 BOM 非 UTF-8)
|
||||||
if e.kind() == std::io::ErrorKind::InvalidData {
|
// 仍走 InvalidData 分支返 binary 标记拦截(图片/编译产物)。
|
||||||
|
let mut raw_bytes = Vec::with_capacity(metadata.len() as usize);
|
||||||
|
if let Err(e) = file.read_to_end(&mut raw_bytes).await {
|
||||||
|
anyhow::bail!("读取文件失败: {}", e);
|
||||||
|
}
|
||||||
|
let content = match decode_bytes_to_string(&raw_bytes) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"path": path, "content": null, "binary": true,
|
"path": path, "content": null, "binary": true,
|
||||||
"size": metadata.len(), "file_hash": file_hash,
|
"size": metadata.len(), "file_hash": file_hash,
|
||||||
"error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取"
|
"error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取"
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
anyhow::bail!("读取文件失败: {}", e);
|
Err(e) => anyhow::bail!("读取文件失败: {}", e),
|
||||||
}
|
};
|
||||||
// search 模式: 按行枚举收集含 search 子串的行,支持 offset/limit 分页
|
// search 模式: 按行枚举收集含 search 子串的行,支持 offset/limit 分页
|
||||||
if let Some(search) = args["search"].as_str() {
|
if let Some(search) = args["search"].as_str() {
|
||||||
const SEARCH_MAX: usize = 50;
|
const SEARCH_MAX: usize = 50;
|
||||||
@@ -201,17 +208,24 @@ pub fn register(
|
|||||||
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
|
anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
|
||||||
}
|
}
|
||||||
let file_hash = compute_file_hash(&metadata);
|
let file_hash = compute_file_hash(&metadata);
|
||||||
let mut content = String::new();
|
// P1 闭环(对齐 read_file L106-124):read_symbol 同受 UTF-16 BOM 硬失败影响——
|
||||||
if let Err(e) = file.read_to_string(&mut content).await {
|
// PowerShell Out-File/> 产 UTF-16 LE BOM 文件 read_to_string 判 InvalidData
|
||||||
if e.kind() == std::io::ErrorKind::InvalidData {
|
// → read_symbol 误判二进制回退提示,治本改 read_to_end + decode_bytes_to_string(BOM 优先解码)。
|
||||||
|
let mut raw_bytes = Vec::with_capacity(metadata.len() as usize);
|
||||||
|
if let Err(e) = file.read_to_end(&mut raw_bytes).await {
|
||||||
|
anyhow::bail!("读取文件失败: {}", e);
|
||||||
|
}
|
||||||
|
let content = match decode_bytes_to_string(&raw_bytes) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::InvalidData => {
|
||||||
return Ok(serde_json::json!({
|
return Ok(serde_json::json!({
|
||||||
"path": path, "binary": true, "size": metadata.len(), "file_hash": file_hash,
|
"path": path, "binary": true, "size": metadata.len(), "file_hash": file_hash,
|
||||||
"fallback": true, "reason": "binary",
|
"fallback": true, "reason": "binary",
|
||||||
"suggestion": "文件非 UTF-8 文本,无法 AST 解析,用 grep 搜内容",
|
"suggestion": "文件非 UTF-8 文本,无法 AST 解析,用 grep 搜内容",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
anyhow::bail!("读取文件失败: {}", e);
|
Err(e) => anyhow::bail!("读取文件失败: {}", e),
|
||||||
}
|
};
|
||||||
// 调 code_intel 纯函数(三态 + 兜底,不 panic)
|
// 调 code_intel 纯函数(三态 + 兜底,不 panic)
|
||||||
Ok(crate::commands::ai::code_intel::read_symbol(
|
Ok(crate::commands::ai::code_intel::read_symbol(
|
||||||
&content, &file_hash, path, symbol, full, kind_hint, drill,
|
&content, &file_hash, path, symbol, full, kind_hint, drill,
|
||||||
@@ -417,12 +431,18 @@ pub fn register(
|
|||||||
let _patch_guard = FILE_LOCKS.lock().await;
|
let _patch_guard = FILE_LOCKS.lock().await;
|
||||||
|
|
||||||
// 读文件内容 + 校验(锁内,纯读 + CPU 计算)
|
// 读文件内容 + 校验(锁内,纯读 + CPU 计算)
|
||||||
|
// P1 闭环(对齐 read_file L106-124):patch_file 同受 UTF-16 BOM 硬失败影响——
|
||||||
|
// read_to_string 对 UTF-16 LE BOM 文件判 InvalidData,旧实现直接 bail 中断 patch,
|
||||||
|
// 用户 PowerShell 编辑的 UTF-16 文件无法 patch。改 read_to_end + decode_bytes_to_string
|
||||||
|
// (BOM 优先解码),解码后内容替换等基于正确解码的字符串,new_content 写回 UTF-8。
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
let mut file = tokio::fs::File::open(path).await
|
let mut file = tokio::fs::File::open(path).await
|
||||||
.map_err(|e| anyhow::anyhow!("读取文件失败 {}: {}", path, e))?;
|
.map_err(|e| anyhow::anyhow!("读取文件失败 {}: {}", path, e))?;
|
||||||
let mut content = String::new();
|
let mut raw_bytes = Vec::with_capacity(file_meta.len() as usize);
|
||||||
file.read_to_string(&mut content).await
|
file.read_to_end(&mut raw_bytes).await
|
||||||
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
|
||||||
|
let content = decode_bytes_to_string(&raw_bytes)
|
||||||
|
.map_err(|e| anyhow::anyhow!("读取文件失败(解码): {}", e))?;
|
||||||
|
|
||||||
// 二进制检测
|
// 二进制检测
|
||||||
if content.contains('\0') {
|
if content.contains('\0') {
|
||||||
@@ -790,6 +810,7 @@ pub fn register(
|
|||||||
props.insert("-n".into(), serde_json::json!({ "type": "boolean", "description": "content 模式是否含行号(默认 true)" }));
|
props.insert("-n".into(), serde_json::json!({ "type": "boolean", "description": "content 模式是否含行号(默认 true)" }));
|
||||||
props.insert("-i".into(), serde_json::json!({ "type": "boolean", "description": "大小写不敏感(默认 false,大小写敏感)" }));
|
props.insert("-i".into(), serde_json::json!({ "type": "boolean", "description": "大小写不敏感(默认 false,大小写敏感)" }));
|
||||||
props.insert("-C".into(), serde_json::json!({ "type": "integer", "description": "上下文行数(content 模式,命中行前后各 N 行,默认 0)", "minimum": 0, "maximum": 10 }));
|
props.insert("-C".into(), serde_json::json!({ "type": "integer", "description": "上下文行数(content 模式,命中行前后各 N 行,默认 0)", "minimum": 0, "maximum": 10 }));
|
||||||
|
props.insert("context_chars".into(), serde_json::json!({ "type": "integer", "description": "字符级窗口(content 模式,大单行>阈值时截匹配位置 ±N 字符窗口替代整行,默认 0 即不截)。与 -C 互补:-C 行级前后 N 行,context_chars 同长行内字符级截窗口", "minimum": 0, "maximum": 2000 }));
|
||||||
props.insert("max_results".into(), serde_json::json!({ "type": "integer", "description": "返回上限(防撑爆 context,默认 50)", "minimum": 1, "maximum": 200 }));
|
props.insert("max_results".into(), serde_json::json!({ "type": "integer", "description": "返回上限(防撑爆 context,默认 50)", "minimum": 1, "maximum": 200 }));
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -801,7 +822,7 @@ pub fn register(
|
|||||||
registry,
|
registry,
|
||||||
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||||||
"grep",
|
"grep",
|
||||||
"跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
|
"跨文件内容搜索(grep -rn 模式)。参数:pattern(正则,大小写敏感,无特殊字符时等价字面包含)、path(搜索根,可选,不传时返回引导提示)、glob(可选文件名过滤如 *.rs)、output_mode(content/files_with_matches/count)、-n(行号默认 true)、-i(大小写不敏感默认 false)、-C(上下文行数默认 0)、context_chars(字符级窗口,大单行如 minified JS/CSS 命中行超阈值时截匹配位置 ±N 字符窗口替代整行,默认 0 不截;与 -C 互补:-C 行级前后 N 行,context_chars 同长行内字符级)、max_results(上限默认 50)。跳过噪音目录/噪音文件/symlink/二进制文件。返回 matches(files_with_matches 模式)或 matches(含 file/line/content/context,content 模式)+ total + truncated。授权目录内放行,未授权触发目录授权申请(AiDirAuthRequired)",
|
||||||
RiskLevel::Low,
|
RiskLevel::Low,
|
||||||
schema: grep_schema,
|
schema: grep_schema,
|
||||||
args => {
|
args => {
|
||||||
@@ -821,6 +842,10 @@ pub fn register(
|
|||||||
let case_insensitive = args.get("-i").and_then(|v| v.as_bool()).unwrap_or(false);
|
let case_insensitive = args.get("-i").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
let show_line = args.get("-n").and_then(|v| v.as_bool()).unwrap_or(true);
|
let show_line = args.get("-n").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||||
let context_lines = args.get("-C").and_then(|v| v.as_u64()).unwrap_or(0).min(10) as usize;
|
let context_lines = args.get("-C").and_then(|v| v.as_u64()).unwrap_or(0).min(10) as usize;
|
||||||
|
// context_chars:字符级窗口(同长行内截匹配位置 ±N 字符,治 minified JS/CSS 大单行爆 prompt)。
|
||||||
|
// 与 -C 互补:-C 行级前后 N 行;context_chars 行内字符级。默认 0 即不截(context_chars=0 照旧返回整行)。
|
||||||
|
const LARGE_LINE_THRESHOLD: usize = 200;
|
||||||
|
let context_chars = args.get("context_chars").and_then(|v| v.as_u64()).unwrap_or(0).min(2000) as usize;
|
||||||
let output_mode = args.get("output_mode").and_then(|v| v.as_str()).unwrap_or("content");
|
let output_mode = args.get("output_mode").and_then(|v| v.as_str()).unwrap_or("content");
|
||||||
let max_results = args.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50).clamp(1, 200) as usize;
|
let max_results = args.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50).clamp(1, 200) as usize;
|
||||||
|
|
||||||
@@ -912,14 +937,41 @@ pub fn register(
|
|||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// content 模式(默认):展平所有命中行为 matches[{file,line,content,context?}]
|
// content 模式(默认):展平所有命中行为 matches[{file,line,content,context?}]
|
||||||
|
// context_chars 字符级窗口(handler 层,不动 grep_one_file/grep_recursive):
|
||||||
|
// 大单行(minified JS/CSS)lm.content 是整行,可能上万字符撑爆 prompt。
|
||||||
|
// 若 context_chars>0 且该行长度 > 阈值 → 截取首个匹配位置 ±context_chars 字符窗口,
|
||||||
|
// 替换 content 并加 "…(±N 字符窗口)…" 标记。-C 行级上下文不受影响(整行 context 仍按行原样)。
|
||||||
let mut lines: Vec<serde_json::Value> = Vec::new();
|
let mut lines: Vec<serde_json::Value> = Vec::new();
|
||||||
for hit in &matches_out {
|
for hit in &matches_out {
|
||||||
for lm in &hit.line_matches {
|
for lm in &hit.line_matches {
|
||||||
let line_no = if show_line { serde_json::Value::from(lm.line) } else { serde_json::Value::Null };
|
let line_no = if show_line { serde_json::Value::from(lm.line) } else { serde_json::Value::Null };
|
||||||
|
// 字符级窗口:content_chars>0 且行超阈值 → 截窗口(content_field 替换整行为窗口)
|
||||||
|
let content_field: serde_json::Value = if context_chars > 0
|
||||||
|
&& lm.content.chars().count() > LARGE_LINE_THRESHOLD
|
||||||
|
{
|
||||||
|
let line_str = lm.content.as_str();
|
||||||
|
// 找首个匹配位置(re.find):window 中心,未匹配到(content 来自非正则路径)时行首
|
||||||
|
let match_byte = re.find(line_str)
|
||||||
|
.map(|m| m.start())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let char_start = line_str[..match_byte.min(line_str.len())].chars().count();
|
||||||
|
let total_chars = line_str.chars().count();
|
||||||
|
let win_start = char_start.saturating_sub(context_chars);
|
||||||
|
let win_end = (char_start + context_chars).min(total_chars);
|
||||||
|
let window: String = line_str.chars().skip(win_start).take(win_end - win_start).collect();
|
||||||
|
serde_json::Value::String(format!(
|
||||||
|
"{}…(±{}字符窗口)…{}",
|
||||||
|
if win_start > 0 { "…" } else { "" },
|
||||||
|
context_chars,
|
||||||
|
window
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
serde_json::Value::String(lm.content.clone())
|
||||||
|
};
|
||||||
let mut entry = serde_json::json!({
|
let mut entry = serde_json::json!({
|
||||||
"file": hit.file,
|
"file": hit.file,
|
||||||
"line": line_no,
|
"line": line_no,
|
||||||
"content": lm.content,
|
"content": content_field,
|
||||||
});
|
});
|
||||||
if context_lines > 0 && !lm.context.is_empty() {
|
if context_lines > 0 && !lm.context.is_empty() {
|
||||||
entry["context"] = serde_json::Value::String(lm.context.clone());
|
entry["context"] = serde_json::Value::String(lm.context.clone());
|
||||||
@@ -1143,5 +1195,159 @@ pub fn register(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// (b) read_symbol / patch_file UTF-16 BOM 闭环测试
|
||||||
|
//
|
||||||
|
// 两 handler 改 read_to_end + decode_bytes_to_string 后,UTF-16 BOM 文件应正确解码为 UTF-8
|
||||||
|
// 字符串(而非 InvalidData 硬失败)。此处直接测 decode_bytes_to_string(两 handler 共用的
|
||||||
|
// 解码入口)在 BOM 各形态下的行为,证明 handler 走 Ok 分支而非 binary 回退 / bail。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// UTF-16 LE BOM 文件(PowerShell Out-File 默认产物)应被 decode_bytes_to_string 正确解码。
|
||||||
|
/// read_symbol/patch_file 旧 read_to_string 路径在此场景判 InvalidData 硬失败,
|
||||||
|
/// 改用 decode_bytes_to_string 后应返回正确内容(中文 + ASCII 混合)。
|
||||||
|
#[test]
|
||||||
|
fn test_decode_utf16_le_bom_reads_correctly() {
|
||||||
|
// 原文:含 ASCII + 中文,覆盖 PowerShell Out-File 编辑的典型内容
|
||||||
|
let original = "fn 核心逻辑() { return 42; }";
|
||||||
|
// 组装 UTF-16 LE BOM 字节流:FF FE + 每字符 LE u16
|
||||||
|
let mut bytes: Vec<u8> = vec![0xFF, 0xFE];
|
||||||
|
for unit in original.encode_utf16() {
|
||||||
|
bytes.extend_from_slice(&unit.to_le_bytes());
|
||||||
|
}
|
||||||
|
let decoded = decode_bytes_to_string(&bytes).expect("UTF-16 LE BOM 应解码成功");
|
||||||
|
assert_eq!(decoded, original, "解码后内容应与原文一致(中文不丢失/不错码)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UTF-16 BE BOM(FE FF)同样应解码成功(对称性,decode_bytes_to_string 两端都支持)。
|
||||||
|
#[test]
|
||||||
|
fn test_decode_utf16_be_bom_reads_correctly() {
|
||||||
|
let original = "struct 符号 { x: i32 }";
|
||||||
|
let mut bytes: Vec<u8> = vec![0xFE, 0xFF];
|
||||||
|
for unit in original.encode_utf16() {
|
||||||
|
bytes.extend_from_slice(&unit.to_be_bytes());
|
||||||
|
}
|
||||||
|
let decoded = decode_bytes_to_string(&bytes).expect("UTF-16 BE BOM 应解码成功");
|
||||||
|
assert_eq!(decoded, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 真 UTF-8 无 BOM 文件(常态)不受影响——decode_bytes_to_string 回退 UTF-8 解码。
|
||||||
|
/// 证明改动未破现有 read_symbol/patch_file 对普通 UTF-8 文件的行为。
|
||||||
|
#[test]
|
||||||
|
fn test_decode_plain_utf8_no_bom_unchanged() {
|
||||||
|
let original = "const x = '普通 UTF-8 无 BOM';";
|
||||||
|
let bytes = original.as_bytes();
|
||||||
|
let decoded = decode_bytes_to_string(bytes).expect("UTF-8 无 BOM 应解码成功");
|
||||||
|
assert_eq!(decoded, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 真二进制(无 BOM 含 \0)应判 InvalidData,read_symbol 走 binary 回退 / patch_file bail。
|
||||||
|
/// 证明 decode_bytes_to_string 仍能识别真二进制(不误放行)。
|
||||||
|
#[test]
|
||||||
|
fn test_decode_real_binary_returns_invalid_data() {
|
||||||
|
let bytes = [0x00, 0x01, 0x02, 0xFF, 0xFE, 0x00, 0x03]; // 含 \0 无 BOM
|
||||||
|
let result = decode_bytes_to_string(&bytes);
|
||||||
|
match result {
|
||||||
|
Err(e) => assert_eq!(
|
||||||
|
e.kind(),
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"真二进制应返 InvalidData"
|
||||||
|
),
|
||||||
|
Ok(_) => panic!("真二进制不应解码成功"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// (a) grep context_chars 大单行 ±N 字符窗口测试
|
||||||
|
//
|
||||||
|
// context_chars 窗口逻辑嵌在 declare_tool! 宏内(handler body),无法直接单测。
|
||||||
|
// 此处抽 build_context_char_window 自由函数 1:1 镜像 handler 内的截窗口算法,
|
||||||
|
// 测试它覆盖:大单行截窗口/小单行不截/窗口边界 clamp/context_chars=0 不截。
|
||||||
|
// 算法与 handler 内字面一致,任何改动需同步(handler 注释已标注此函数名)。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 字符级窗口算法(与 grep handler content 模式内字面 1:1 一致,handler 注释引用此函数名)。
|
||||||
|
/// 给定整行、匹配正则、context_chars、阈值;行 > 阈值且 context_chars>0 → 返回截窗口后的 content
|
||||||
|
/// 字段值(含 "…(±N字符窗口)…" 标记);否则返回整行原样。
|
||||||
|
fn build_context_char_window(
|
||||||
|
line: &str,
|
||||||
|
re: &Regex,
|
||||||
|
context_chars: usize,
|
||||||
|
threshold: usize,
|
||||||
|
) -> String {
|
||||||
|
if context_chars == 0 || line.chars().count() <= threshold {
|
||||||
|
return line.to_string();
|
||||||
|
}
|
||||||
|
let match_byte = re.find(line).map(|m| m.start()).unwrap_or(0);
|
||||||
|
let char_start = line[..match_byte.min(line.len())].chars().count();
|
||||||
|
let total_chars = line.chars().count();
|
||||||
|
let win_start = char_start.saturating_sub(context_chars);
|
||||||
|
let win_end = (char_start + context_chars).min(total_chars);
|
||||||
|
let window: String = line.chars().skip(win_start).take(win_end - win_start).collect();
|
||||||
|
format!(
|
||||||
|
"{}…(±{}字符窗口)…{}",
|
||||||
|
if win_start > 0 { "…" } else { "" },
|
||||||
|
context_chars,
|
||||||
|
window
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 大单行 + context_chars>0 → 截匹配位置 ±N 字符窗口,minified JS 场景不爆 prompt。
|
||||||
|
#[test]
|
||||||
|
fn test_context_chars_window_large_line() {
|
||||||
|
// 模拟 minified JS:很长一行(>200 阈值),中间含 "function core"
|
||||||
|
let filler = "a".repeat(300);
|
||||||
|
let line = format!("{}function core(){{return 42;}}{}", filler, filler);
|
||||||
|
let re = Regex::new("function core").unwrap();
|
||||||
|
let windowed = build_context_char_window(&line, &re, 30, 200);
|
||||||
|
// 应含窗口标记,且长度远小于整行(整行 ~630 字符)
|
||||||
|
assert!(windowed.contains("(±30字符窗口)"), "应含窗口标记");
|
||||||
|
assert!(windowed.starts_with("…"), "match 前有内容应前导 …");
|
||||||
|
assert!(
|
||||||
|
windowed.chars().count() < line.chars().count(),
|
||||||
|
"窗口应短于整行"
|
||||||
|
);
|
||||||
|
// 窗口应含匹配核心文本(function core),±30 字符足够覆盖
|
||||||
|
assert!(windowed.contains("function core"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// context_chars=0 → 不截,返回整行原样(默认行为,不破现有 grep)。
|
||||||
|
#[test]
|
||||||
|
fn test_context_chars_zero_returns_full_line() {
|
||||||
|
let line = "a".repeat(500); // 大单行
|
||||||
|
let re = Regex::new("a").unwrap();
|
||||||
|
let result = build_context_char_window(&line, &re, 0, 200);
|
||||||
|
assert_eq!(result, line, "context_chars=0 应返回整行(默认行为不破)");
|
||||||
|
assert!(!result.contains("字符窗口"), "不应加窗口标记");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 行 ≤ 阈值(普通短行)→ 不截,即使 context_chars>0(避免短行被无谓截断)。
|
||||||
|
#[test]
|
||||||
|
fn test_context_chars_small_line_not_truncated() {
|
||||||
|
let line = "let x = 42; // 短行";
|
||||||
|
let re = Regex::new("x").unwrap();
|
||||||
|
let result = build_context_char_window(&line, &re, 100, 200);
|
||||||
|
assert_eq!(result, line, "行 ≤ 阈值应原样返回");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 匹配在行首 → 前导 … 不出现(win_start=0),仅后置窗口 + 标记。
|
||||||
|
#[test]
|
||||||
|
fn test_context_chars_match_at_line_start() {
|
||||||
|
let prefix = "";
|
||||||
|
let suffix = "b".repeat(300);
|
||||||
|
let line = format!("{}function core(){{}}{}", prefix, suffix);
|
||||||
|
let re = Regex::new("function core").unwrap();
|
||||||
|
let windowed = build_context_char_window(&line, &re, 20, 200);
|
||||||
|
assert!(!windowed.starts_with("…"), "match 在行首,win_start=0 不前导 …");
|
||||||
|
assert!(windowed.contains("(±20字符窗口)"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// probe_executable 仍在 super::tool_registry(detect_environment 原引用),迁移后该私有 fn
|
// probe_executable 仍在 super::tool_registry(detect_environment 原引用),迁移后该私有 fn
|
||||||
// 仅 file 层 detect_environment handler 引用 → 改 pub(crate),本模块 use 引入(见上方 import)。
|
// 仅 file 层 detect_environment handler 引用 → 改 pub(crate),本模块 use 引入(见上方 import)。
|
||||||
|
|||||||
Reference in New Issue
Block a user