//! 文件系统类 AI 工具声明式注册(register_file_tools 13 个迁入)。
//!
//! 迁自 `tool_registry.rs::register_file_tools`(原 13 个 read_file/read_symbol/
//! list_directory/write_file/patch_file/file_info/append_file/delete_file/rename_file/
//! grep/search_files/run_command/detect_environment),改用 `declare_tool!` 宏。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 `register_file_tools` 内 async move 块(逻辑等价),
//!   仅闭包包装(`{ let allowed_dirs = ...; Box::new(move |args| { ... Box::pin(async move {...}) }) }`)
//!   改由 `declare_tool!` 宏生成。
//! - name/desc/schema/risk 与原手写定义逐字一致。
//! - 复用 super::tool_registry 的常量/函数(单真相源,迁移后这些私有 fn/const 改 pub(crate)):
//!     DEFAULT_RUN_COMMAND_TIMEOUT_SECS / MAX_RUN_COMMAND_TIMEOUT_SECS / truncate_output /
//!     validate_path / generate_diff / apply_line_range / resolve_anchor_to_lines /
//!     resolve_workspace_path_with_allowed / rename_or_cross_volume_copy / compute_file_hash /
//!     FILE_LOCKS / probe_executable / list_dir_recursive / compile_glob_to_regex /
//!     grep_recursive / grep_one_file / search_files_recursive / is_noise_dir / is_noise_file /
//!     FileGrepHit / LineMatch。
//!
//! 单捕获宏适配(declare_tool! 宏仅支持单 capture 变量):
//! - 文件工具主体捕获 `allowed_dirs: Arc<RwLock<AllowedDirs>>`。
//! - delete_file 额外捕获 data_dir:PathBuf,用打包 capture
//!   `delete_ctx: Arc<(Arc<RwLock<AllowedDirs>>, PathBuf)>`,handler 首行解包为
//!   `let allowed_dirs = &delete_ctx.0; let data_dir = &delete_ctx.1;`,其余 body 逐字不变。
//! - run_command/detect_environment 零捕获(原 `Box::new(|args| ...)` 不含 allowed_dirs),
//!   用 `Arc<()>` 占位 capture(handler body 不引用它),对齐 declare_tool_no_capture 测试模式。
//!
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
//! 工具名集合稳定(防 rename / 漏注册)。

use std::sync::Arc;

use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::declare_tool;
use tokio::sync::RwLock;

use crate::state::AllowedDirs;

// 复用 super::tool_registry 的常量/函数(单真相源,迁移后这些私有项已改 pub(crate))
use crate::commands::ai::tool_registry::{
    apply_line_range, compile_glob_to_regex, compute_file_hash, decode_bytes_to_string,
    generate_diff, grep_one_file, grep_recursive, list_dir_recursive, probe_executable,
    rename_or_cross_volume_copy, resolve_anchor_to_lines, resolve_workspace_path_with_allowed,
    search_files_recursive, truncate_output, validate_path, DEFAULT_RUN_COMMAND_TIMEOUT_SECS,
    MAX_RUN_COMMAND_TIMEOUT_SECS, FILE_LOCKS, FileGrepHit,
};

// run_command 依赖 df_execute::shell::{execute_streaming, ShellRequest, StreamKind} + std HashMap
// execute_streaming:run_command 专用流式(spawn 逐行读 stdout/stderr,回调 emit AiCommandOutput 治执行黑盒)。
// new_id: delete_file 软删除备份命名(原 register_file_tools 闭包引用,经 df_types::types::new_id)。
use df_execute::shell::{execute_streaming, ShellRequest, StreamKind};
use df_types::types::new_id;
use std::collections::HashMap;

/// 注册 13 个文件系统类工具到 `$registry`。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk 与原一致(read/symbol/list/file_info/search/grep/detect_environment=Low,
///   write/patch/append/rename=Medium,delete/run_command=High)
/// - handler body 与原 async move 块逐字一致(逻辑零变更,路径授权/risk/diff/patch 全保留)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(
    registry: &mut AiToolRegistry,
    allowed_dirs: &Arc<RwLock<AllowedDirs>>,
    data_dir: std::path::PathBuf,
) {
    // ── read_file (Low 只读) ──
    declare_tool!(
        registry,
        allowed_dirs: Arc<RwLock<AllowedDirs>>,
        "read_file",
        "读取文件内容，返回文本内容。支持 offset/limit 分页；传入 search 则在文件内容中搜索匹配行（大小写敏感，字符串包含匹配），返回 matches 数组（限50条）。仅需函数/类型符号的签名或定义体时优先用 read_symbol（骨架/全文，避免 prompt 爆）",
        RiskLevel::Low,
        schema: object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]),
        args => {
            let snap = allowed_dirs.read().await.clone();
            let resolved = resolve_workspace_path_with_allowed(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
                &snap,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            // 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
        use tokio::fs::File;
        use tokio::io::AsyncReadExt;
        let mut file = File::open(path).await
            .map_err(|e| {
                if e.kind() == std::io::ErrorKind::NotFound {
                    anyhow::anyhow!(
                        "无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
                        path
                    )
                } else {
                    anyhow::anyhow!("无法访问文件 {}: {}", path, e)
                }
            })?;
        let metadata = file.metadata().await
            .map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
        if metadata.len() > 1_048_576 {
            anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
        }
        // TD-260621-04 闭环:返回 file_hash 供 patch_file.expected_hash 比对(防并发修改)。
        // 三返回点(二进制降级/search/默认分页)共用此值,文件未改时跨分页稳定。
        let file_hash = compute_file_hash(&metadata);
        // P1 修复:原始字节读取 + UTF-16 LE/BE BOM 优先解码,治 PowerShell Out-File/> 产
        // UTF-16 LE BOM 文件被 read_to_string 判 InvalidData(ASCII 高字节 0x00)→ 误判二进制拒读
        // 致 run_command → 文件 → read_file 链路在 Windows 断裂。真二进制(无 BOM 非 UTF-8)
        // 仍走 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!({
                    "path": path, "content": null, "binary": true,
                    "size": metadata.len(), "file_hash": file_hash,
                    "error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取"
                }));
            }
            Err(e) => anyhow::bail!("读取文件失败: {}", e),
        };
        // search 模式: 按行枚举收集含 search 子串的行,支持 offset/limit 分页
        if let Some(search) = args["search"].as_str() {
            const SEARCH_MAX: usize = 50;
            let search_offset = args["offset"].as_u64().unwrap_or(0) as usize;
            let search_limit = args["limit"].as_u64().unwrap_or(SEARCH_MAX as u64).min(SEARCH_MAX as u64) as usize;
            // 先收集全部匹配行用于 total 计数，再做 skip/take 分页
            let mut all_matches: Vec<serde_json::Value> = Vec::new();
            for (idx, line) in content.lines().enumerate() {
                if line.contains(search) {
                    all_matches.push(serde_json::json!({ "line": idx + 1, "content": line }));
                }
            }
            let total = all_matches.len();
            let page_matches: Vec<_> = all_matches.into_iter().skip(search_offset).take(search_limit).collect();
            let has_more = (search_offset + page_matches.len()) < total;
            return Ok(serde_json::json!({
                "path": path, "size": metadata.len(), "file_hash": file_hash,
                "search": search,
                "matches": page_matches,
                "total": total,
                "has_more": has_more,
            }));
        }
        // 默认分页模式: limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行)
        // BUG-260617-11: 无 offset 时旧实现 content.clone() 全量返回大文件,
        // 虽 1MB 字节上限挡住极端情况,但万行级源码全量进 LLM context 仍易撑爆。
        // 改:无 offset 默认返回前 500 行 + has_more 提示翻页(对齐 read 工具常规用法)。
        let line_count = content.lines().count();
        let (result, offset_used, has_more) = if let Some(offset) = args["offset"].as_u64() {
            let lines: Vec<&str> = content.lines().collect();
            let skip = offset as usize;
            let limit = args["limit"].as_u64().unwrap_or(200).min(2000) as usize;
            let page: Vec<&str> = lines.into_iter().skip(skip).take(limit).collect();
            let more = (skip + page.len()) < line_count;
            (page.join("\n"), Some(skip), more)
        } else {
            // 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义),
            // 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。
            // BUG-260623-01: 旧实现固定 take(500) 忽略 limit,
            // 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
            const DEFAULT_PREVIEW_LINES: usize = 500;
            let limit = args["limit"].as_u64()
                .unwrap_or(DEFAULT_PREVIEW_LINES as u64)
                .min(2000) as usize;
            let page: Vec<&str> = content.lines().take(limit).collect();
            let more = line_count > page.len();
            (page.join("\n"), None, more)
        };
        Ok(serde_json::json!({
            "path": path, "content": result, "size": metadata.len(), "file_hash": file_hash, "lines": line_count,
            "offset": offset_used,
            "returned_lines": result.lines().count(),
            "has_more": has_more,
        }))
        }
    );

    // ── read_symbol (Low 只读, AST 符号解析) ──
    declare_tool!(
        registry,
        allowed_dirs: Arc<RwLock<AllowedDirs>>,
        "read_symbol",
        "AST 符号解析(信息密度驱动,治 read_file 全文回灌 prompt 爆)。提取函数/结构体/类等符号:默认返回骨架(签名+行范围+内部调用,极小高密度);传 full=true 取完整定义体。不支持/未找到时回退提示用 grep+read_file。Phase1 支持 .rs/.ts/.tsx/.js/.jsx/.vue。",
        RiskLevel::Low,
        schema: object_schema(vec![("path", "string", true), ("symbol", "string", true), ("full", "boolean", false), ("kind", "string", false), ("drill", "string", false)]),
        args => {
            let snap = allowed_dirs.read().await.clone();
            let resolved = resolve_workspace_path_with_allowed(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
                &snap,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let symbol = args["symbol"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 symbol 参数"))?;
            let full = args["full"].as_bool().unwrap_or(false);
            let kind_hint = args["kind"].as_str();
            let drill = args["drill"].as_str().filter(|s| !s.is_empty());
        use tokio::fs::File;
        use tokio::io::AsyncReadExt;
        let mut file = File::open(path).await
            .map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
        let metadata = file.metadata().await
            .map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
        if metadata.len() > 1_048_576 {
            anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len());
        }
        let file_hash = compute_file_hash(&metadata);
        // P1 闭环(对齐 read_file L106-124):read_symbol 同受 UTF-16 BOM 硬失败影响——
        // PowerShell Out-File/> 产 UTF-16 LE BOM 文件 read_to_string 判 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!({
                    "path": path, "binary": true, "size": metadata.len(), "file_hash": file_hash,
                    "fallback": true, "reason": "binary",
                    "suggestion": "文件非 UTF-8 文本,无法 AST 解析,用 grep 搜内容",
                }));
            }
            Err(e) => anyhow::bail!("读取文件失败: {}", e),
        };
        // 调 code_intel 纯函数(三态 + 兜底,不 panic)
        Ok(crate::commands::ai::code_intel::read_symbol(
            &content, &file_hash, path, symbol, full, kind_hint, drill,
        ))
        }
    );

    // ── list_directory (Low 只读) ──
    declare_tool!(
        registry,
        allowed_dirs: Arc<RwLock<AllowedDirs>>,
        "list_directory",
        "列出目录内容，返回文件和子目录列表（名称、类型、大小）。path 可选，不传时返回引导提示",
        RiskLevel::Low,
        schema: object_schema(vec![("path", "string", false), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
        args => {
            let p = args["path"].as_str().unwrap_or("");
            if p.is_empty() {
                return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录，或直接提供绝对路径。"}));
            }
            let snap = allowed_dirs.read().await.clone();
            let resolved = resolve_workspace_path_with_allowed(p, &snap)?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let recursive = args["recursive"].as_bool().unwrap_or(false);
            let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
            // BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000)
            // 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。
            let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize;
            let mut entries = Vec::new();
            let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?;
            Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated }))
        }
    );

    // ── write_file (Medium 写) ──
    declare_tool!(
        registry,
        allowed_dirs: Arc<RwLock<AllowedDirs>>,
        "write_file",
        "写入或创建文件，自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容（图片/PDF/Excel 等），content 传 base64 编码字符串；默认 encoding=\"utf-8\" 传纯文本",
        RiskLevel::Medium,
        schema: object_schema(vec![
            ("path", "string", true),
            ("content", "string", true),
            ("encoding", "string", false),
        ]),
        args => {
            let snap = allowed_dirs.read().await.clone();
            let resolved = resolve_workspace_path_with_allowed(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
                &snap,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
            // F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等)
            // 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节
            let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
            let bytes: Vec<u8> = if encoding.eq_ignore_ascii_case("base64") {
                use base64::{engine::general_purpose::STANDARD, Engine as _};
                STANDARD.decode(raw)
                    .map_err(|e| anyhow::anyhow!("base64 解码失败: {}", e))?
            } else if encoding.eq_ignore_ascii_case("utf-8") {
                raw.as_bytes().to_vec()
            } else {
                anyhow::bail!("不支持的 encoding: {}（仅 utf-8 / base64）", encoding);
            };
            // 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史)
            if bytes.len() > 1_048_576 {
                anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", bytes.len());
            }
            let target = std::path::Path::new(path);
            // FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失)
            // 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节
            let old_size: Option<u64> = match tokio::fs::metadata(target).await {
                Ok(m) if m.len() > 0 => {
                    let bak = format!("{}.bak", path);
                    tokio::fs::copy(path, &bak).await
                        .map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;
                    Some(m.len())
                }
                Ok(_) => Some(0), // 空文件(无需备份)
                Err(_) => None,   // 不存在(新建)
            };
            if let Some(parent) = target.parent() {
                // FR-S8:parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。
                // BUG-260623-01:原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目
                // 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。
                if !snap.is_authorized(parent) {
                    anyhow::bail!("禁止在项目目录之外创建目录");
                }
                tokio::fs::create_dir_all(parent).await
                    .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
            }
            // FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷)
            let tmp = format!("{}.tmp-write", path);
            if let Err(e) = tokio::fs::write(&tmp, &bytes).await {
                let _ = tokio::fs::remove_file(&tmp).await;
                return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
            }
            if let Err(e) = tokio::fs::rename(&tmp, path).await {
                let _ = tokio::fs::remove_file(&tmp).await;
                return Err(anyhow::anyhow!("原子替换失败: {}", e));
            }
            // R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要);
            // rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。
            // 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件)
            if old_size.map(|s| s > 0).unwrap_or(false) {
                let bak = format!("{}.bak", path);
                let _ = tokio::fs::remove_file(&bak).await;
            }
            // FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak
            if let Some(old) = old_size {
                if old > 0 && (bytes.len() as f64 / old as f64) < 0.1 {
                    tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, bytes.len());
                }
            }
            Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding }))
        }
    );

    // ── patch_file (Medium, 三模式互斥) ──
    // object_schema 只支持扁平标量三元组，故 replace_lines/anchor 嵌套对象手工拼 schema。
    let patch_file_schema = {
        let mut props = serde_json::Map::new();
        props.insert("path".into(), serde_json::json!({ "type": "string", "description": "目标文件路径（必填）" }));
        props.insert("new_text".into(), serde_json::json!({ "type": "string", "description": "替换后的新内容（三模式通用，必填）" }));
        props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配：要替换的原文（必须精确匹配含空格/缩进），三选一互斥" }));
        props.insert("replace_lines".into(), serde_json::json!({
            "type": "object",
            "description": "模式2 行号区间：{ start: 1-based 起始行(含), end: 1-based 结束行(含) }，三选一互斥。配 expected_hash 防并发行号漂移",
            "properties": {
                "start": { "type": "integer", "description": "起始行号（1-based，含）" },
                "end": { "type": "integer", "description": "结束行号（1-based，含）" }
            },
            "required": ["start", "end"]
        }));
        props.insert("anchor".into(), serde_json::json!({
            "type": "object",
            "description": "模式3 锚点：{ start: 首行子串标记, end: 尾行子串标记 }，内部定位首尾行号转区间替换，三选一互斥。不需完整原文",
            "properties": {
                "start": { "type": "string", "description": "首行子串标记（大小写敏感）" },
                "end": { "type": "string", "description": "尾行子串标记（大小写敏感，在 start 行之后）" }
            },
            "required": ["start", "end"]
        }));
        props.insert("line".into(), serde_json::json!({ "type": "integer", "description": "（old_text 模式）行号辅助定位，可选" }));
        props.insert("expected_hash".into(), serde_json::json!({ "type": "string", "description": "可选文件指纹（read_file 返回的 file_hash），三模式通用，防并发修改" }));
        serde_json::json!({
            "type": "object",
            "properties": props,
            "required": ["path", "new_text"],
        })
    };
    declare_tool!(
        registry,
        allowed_dirs: Arc<RwLock<AllowedDirs>>,
        "patch_file",
        "局部更新文件内容（三模式互斥）。模式1 old_text：精确匹配原文替换（含空格/缩进，CAS 语义）。模式2 replace_lines：按行号区间 {start,end}（1-based 含首尾）替换，不需原文，配 expected_hash 防行号漂移。模式3 anchor：按首尾子串锚点 {start,end}（大小写敏感，子串匹配）定位行号区间替换，不需完整原文。三模式均需 path+new_text，expected_hash 可选通用。注意：若文件已被外部修改，请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流，写入会落 audit 表可追溯。",
        RiskLevel::Medium,
        schema: patch_file_schema,
        args => {
            let snap = allowed_dirs.read().await.clone();
            let resolved = resolve_workspace_path_with_allowed(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
                &snap,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let new_text = args["new_text"].as_str()
                .ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;

            // F-260617-01: 三模式互斥校验（old_text / replace_lines / anchor）
            // 三选一：统计传入的模式参数数，>1 报错，0 报错（缺定位方式）
            let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false);
            let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false);
            let has_anchor = args.get("anchor").map(|v| !v.is_null()).unwrap_or(false);
            let mode_count = [has_old_text, has_replace_lines, has_anchor].iter().filter(|&&b| b).count();
            if mode_count == 0 {
                anyhow::bail!("缺少定位方式：必须提供 old_text / replace_lines / anchor 之一");
            }
            if mode_count > 1 {
                anyhow::bail!(
                    "模式互斥冲突：old_text / replace_lines / anchor 仅可传一个（检测到 {} 个）",
                    mode_count
                );
            }

            // 文件存在性 / 大小限制（三模式通用，先于内容读取）
            let target = std::path::Path::new(path);
            if !target.exists() {
                anyhow::bail!("文件不存在: {}", path);
            }
            let file_meta = tokio::fs::metadata(target).await
                .map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
            if file_meta.len() > 1_048_576 {
                anyhow::bail!("文件超过 1MB 限制 ({} 字节)", file_meta.len());
            }

            // TD-260621-03:读改写整体锁内防 lost update。
            // 原实现读+校验+new_content 计算在无锁段,仅写序列持 FILE_LOCKS → 两并发 patch 同文件:
            // A/B 各自读 v1 算 new_content(锁外)→ A 持锁写 v2 释放 → B 持锁用基于 v1 的 new_content 覆盖 A。
            // 改:读+校验+算+写 全程持 _patch_guard,串行化 patch(全局锁,单用户桌面够用,见 FILE_LOCKS 注释)。
            // 顺带修 entry().or_insert(()) 内存泄漏(FILE_LOCKS HashMap 只增不清,P2 精选项)。
            let _patch_guard = FILE_LOCKS.lock().await;

            // 读文件内容 + 校验（锁内,纯读 + 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;
            let mut file = tokio::fs::File::open(path).await
                .map_err(|e| anyhow::anyhow!("读取文件失败 {}: {}", path, e))?;
            let mut raw_bytes = Vec::with_capacity(file_meta.len() as usize);
            file.read_to_end(&mut raw_bytes).await
                .map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?;
            let content = decode_bytes_to_string(&raw_bytes)
                .map_err(|e| anyhow::anyhow!("读取文件失败(解码): {}", e))?;

            // 二进制检测
            if content.contains('\0') {
                anyhow::bail!("不支持二进制文件");
            }

            // L3: expected_hash 指纹校验（防外部修改，TD-260621-04 闭环：与 read_file 返回的 file_hash 同格式）
            if let Some(expected) = args["expected_hash"].as_str() {
                let current_hash = compute_file_hash(&file_meta);
                if current_hash != expected {
                    anyhow::bail!(
                        "文件已被外部修改(hash 不匹配): 期望={} 实际={}，请重新 read_file 获取最新内容",
                        expected, current_hash
                    );
                }
            }

            // F-260617-01: 三模式分派计算 new_content + match_count + warning
            // old_text 模式：精确匹配（CAS）+ 多匹配警告（仅替换第 1 处）
            // replace_lines 模式：行号区间 splice（越界 Err）
            // anchor 模式：子串锚点定位 → 行号区间 splice（找不到/start 在 end 后 Err）
            let new_content: String;
            let match_count: usize;
            let warning: Option<String>;

            if has_old_text {
                let old_text = args["old_text"].as_str()
                    .ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?;
                if old_text.is_empty() {
                    anyhow::bail!("old_text 不能为空");
                }
                if old_text == new_text {
                    return Ok(serde_json::json!({
                        "path": path, "changed": false, "warning": "new_text 与 old_text 相同，无实际更改"
                    }));
                }
                // L2: old_text 精确匹配（CAS 语义）
                if !content.contains(old_text) {
                    anyhow::bail!("未找到目标文本，文件可能已被修改");
                }
                let mc = content.matches(old_text).count();
                match_count = mc;
                warning = if mc > 1 {
                    Some(format!("匹配到 {} 处，仅替换第 1 处", mc))
                } else { None };
                new_content = content.replacen(old_text, new_text, 1);
            } else if has_replace_lines {
                let rl = args.get("replace_lines")
                    .ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?;
                let start = rl["start"].as_u64()
                    .ok_or_else(|| anyhow::anyhow!("replace_lines.start 必须为正整数"))? as usize;
                let end = rl["end"].as_u64()
                    .ok_or_else(|| anyhow::anyhow!("replace_lines.end 必须为正整数"))? as usize;
                new_content = apply_line_range(&content, start, end, new_text)?;
                match_count = 1;
                warning = None;
            } else {
                // has_anchor
                let an = args.get("anchor")
                    .ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?;
                let a_start = an["start"].as_str()
                    .ok_or_else(|| anyhow::anyhow!("anchor.start 必须为字符串"))?;
                let a_end = an["end"].as_str()
                    .ok_or_else(|| anyhow::anyhow!("anchor.end 必须为字符串"))?;
                let (start_line, end_line) = resolve_anchor_to_lines(&content, a_start, a_end)?;
                new_content = apply_line_range(&content, start_line, end_line, new_text)?;
                match_count = 1;
                warning = None;
            }

            // 写序列（_patch_guard 持锁中:backup → tmp write → rename → cleanup）
            // .bak 备份
            let bak = format!("{}.bak", path);
            tokio::fs::copy(path, &bak).await
                .map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?;

            // 原子写: tmp → rename
            let tmp = format!("{}.tmp-write", path);
            if let Err(e) = tokio::fs::write(&tmp, &new_content).await {
                let _ = tokio::fs::remove_file(&tmp).await;
                let _ = tokio::fs::remove_file(&bak).await;
                return Err(anyhow::anyhow!("写入临时文件失败: {}", e));
            }
            if let Err(e) = tokio::fs::rename(&tmp, path).await {
                let _ = tokio::fs::remove_file(&tmp).await;
                return Err(anyhow::anyhow!("原子替换失败: {}，备份保留在 {}", e, bak));
            }
            // 成功：清理 .bak
            let _ = tokio::fs::remove_file(&bak).await;
            drop(_patch_guard);  // 释放锁(diff 计算纯 CPU,不需持锁)

            let size_diff = new_content.len() as i64 - content.len() as i64;
            // 生成 unified diff 供前端审批卡/审计留痕展示
            // content（旧）与 new_content（新）作用域内可用，无需重读文件
            let diff = generate_diff(&content, &new_content);
            let mut result = serde_json::json!({
                "path": path,
                "changed": true,
                "size_diff": size_diff,
                "matches_found": match_count,
                "diff": diff,
            });
            if let Some(w) = warning {
                result["warning"] = serde_json::Value::String(w);
            }
            Ok(result)
        }
    );

    // ── file_info (Low 只读) ──
    declare_tool!(
        registry,
        allowed_dirs: Arc<RwLock<AllowedDirs>>,
        "file_info",
        "获取文件或目录的元信息（是否存在、大小、行数、修改时间、是否二进制、是否目录），不读取文件内容",
        RiskLevel::Low,
        schema: object_schema(vec![("path", "string", true)]),
        args => {
            let snap = allowed_dirs.read().await.clone();
            let resolved = resolve_workspace_path_with_allowed(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
                &snap,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let p = std::path::Path::new(path);
            if !p.exists() {
                return Ok(serde_json::json!({ "path": path, "exists": false }));
            }
            let metadata = tokio::fs::metadata(path).await
                .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
            let is_dir = metadata.is_dir();
            let size = metadata.len();
            let modified = metadata.modified()
                .ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                .map(|d| d.as_millis() as i64);
            // is_binary: 流式读前 8KB 检测 \x00 (BUG-260617-02)
            // 旧实现 tokio::fs::read(path) 把整个文件读进内存再切片前 8192,
            // >2MB 文件触发 OOM(注释">2MB 跳过"只作用于 lines, is_binary 无防护)。
            // 改:File::open + BufReader + .take(8192) 只读前 N 字节做二进制检测,
            // size/modified 等元信息另从 metadata 取(上面已取),不依赖全量读。
            let is_binary = if !is_dir && size > 0 {
                use tokio::io::AsyncReadExt;
                let file = match tokio::fs::File::open(path).await {
                    Ok(f) => f,
                    Err(_) => return Ok(serde_json::json!({
                        "path": path, "exists": true, "size": size, "lines": serde_json::Value::Null,
                        "modified": modified, "is_binary": false, "is_dir": is_dir,
                        "error": "读取文件头失败"
                    })),
                };
                let mut reader = tokio::io::BufReader::new(file);
                let mut sample = vec![0u8; 8192];
                let n = reader.read(&mut sample).await.unwrap_or(0);
                sample[..n].contains(&0x00)
            } else { false };
            // lines: 文本文件 \n 计数（>2MB 跳过避免全量读）
            let lines = if !is_dir && !is_binary && size <= 2_097_152 {
                tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64)
            } else { None };
            Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir }))
        }
    );

    // ── append_file (Medium 追加) ──
    declare_tool!(
        registry,
        allowed_dirs: Arc<RwLock<AllowedDirs>>,
        "append_file",
        "向文件末尾追加内容，文件不存在则自动创建。返回写入字数和新文件大小",
        RiskLevel::Medium,
        schema: object_schema(vec![("path", "string", true), ("content", "string", true)]),
        args => {
            let snap = allowed_dirs.read().await.clone();
            let resolved = resolve_workspace_path_with_allowed(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
                &snap,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
            if let Some(parent) = std::path::Path::new(path).parent() {
                // BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
                if !snap.is_authorized(parent) {
                    anyhow::bail!("禁止在项目目录之外创建目录");
                }
                tokio::fs::create_dir_all(parent).await
                    .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?;
            }
            use tokio::io::AsyncWriteExt;
            let mut file = tokio::fs::OpenOptions::new().append(true).create(true).open(path).await
                .map_err(|e| anyhow::anyhow!("打开文件失败: {}", e))?;
            let bytes = content.as_bytes();
            file.write_all(bytes).await.map_err(|e| anyhow::anyhow!("追加写入失败: {}", e))?;
            file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?;
            let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0);
            Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size }))
        }
    );

    // ── delete_file (High, 双捕获 allowed_dirs + data_dir) ──
    // declare_tool! 宏仅支持单 capture。delete_file 同时需 allowed_dirs + data_dir,
    // 故打包为 delete_ctx: Arc<(Arc<RwLock<AllowedDirs>>, PathBuf)> 单 capture,
    // handler 首行解包为 (allowed_dirs, data_dir),其余 body 逐字照搬(逻辑零变更)。
    let delete_ctx = Arc::new((allowed_dirs.clone(), data_dir.clone()));
    declare_tool!(
        registry,
        delete_ctx: Arc<(Arc<RwLock<AllowedDirs>>, std::path::PathBuf)>,
        "delete_file",
        "删除文件。默认（permanent=false）软删除：移到工作区 .trash/ 目录保留可恢复；permanent=true 硬删除不可恢复。删除前备份，回收站机制对齐 list_trash。高风险，须人工批准",
        RiskLevel::High,
        schema: object_schema(vec![
            ("path", "string", true),
            ("permanent", "boolean", false),
        ]),
        args => {
            // 单 capture 宏适配:解包 delete_ctx 取 (allowed_dirs, data_dir),其余 body 逐字照搬
            let allowed_dirs = &delete_ctx.0;
            let data_dir = &delete_ctx.1;
            // validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸
            let snap = allowed_dirs.read().await.clone();
            let resolved = resolve_workspace_path_with_allowed(
                args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
                &snap,
            )?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false);
            let target = std::path::Path::new(path);
            if !target.exists() {
                anyhow::bail!("文件不存在: {}", path);
            }
            // 拒绝目录:delete_file 仅删文件(删目录语义复杂+递归风险大,留 run_command 兜底)
            let metadata = tokio::fs::metadata(target).await
                .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
            if metadata.is_dir() {
                anyhow::bail!("delete_file 仅支持删除文件，不支持目录: {}", path);
            }
            let file_name = target.file_name()
                .ok_or_else(|| anyhow::anyhow!("无法解析文件名: {}", path))?
                .to_string_lossy().to_string();

            if permanent {
                // 硬删除:不可逆,对齐 delete_project purge 语义
                tokio::fs::remove_file(path).await
                    .map_err(|e| anyhow::anyhow!("删除失败: {}", e))?;
                Ok(serde_json::json!({
                    "path": path,
                    "deleted": true,
                    "permanent": true,
                    "backed_up": false,
                }))
            } else {
                // 软删除:移到 .trash/<uuid>-<filename>,可恢复(对齐 list_trash 机制)
                // .trash 放 app_data_dir 下,不依赖编译期 workspace_root
                let trash_dir = data_dir.join(".trash");
                tokio::fs::create_dir_all(&trash_dir).await
                    .map_err(|e| anyhow::anyhow!("创建 .trash 目录失败: {}", e))?;
                let backup_name = format!("{}-{}", new_id(), file_name);
                let backup_path = trash_dir.join(&backup_name);
                let backup_path_str = backup_path.to_string_lossy().to_string();
                // 软删除跨盘降级(F-260621):同盘 rename 原子;跨盘(Windows EXDEV os error 17,
                // 如 C盘授权路径 → data_dir/.trash)rename 失败,降级 copy + remove(非原子,
                // 失败回滚删 backup 保源完整)。
                // 阶段4:跨盘降级抽统一 helper rename_or_cross_volume_copy(对齐 rename_file 跨卷处理),
                // 消除两处 copy+remove 字面量重复。原直接 rename bail 致 delete_file 跨盘场景全失败
                // (用户授权工程外 C 盘路径删除时,前几个卡"执行中..."+ 末个报 os error 17)。
                // .trash 固定在 workspace_root,授权工程外路径删除必然跨盘。
                // helper 内部错误文案含"跨卷复制失败/remove源失败"等,此处 map_err 转成"移入回收站"语义。
                if let Err(e) = rename_or_cross_volume_copy(path, &backup_path_str).await {
                    anyhow::bail!("移入回收站失败({})", e);
                }
                Ok(serde_json::json!({
                    "path": path,
                    "deleted": true,
                    "permanent": false,
                    "backed_up": true,
                    "backup_path": backup_path_str,
                }))
            }
        }
    );

    // ── rename_file (Medium 重命名/移动) ──
    // 一个工具覆盖 rename(同目录改名) + move(跨目录移动)。绕开 shell.rs PS/cmd 中文路径 GBK 解码
    // 静默失败根因:Rust 原生 std::fs 走 Win32 UTF-16 路径,中文路径零字符集问题。
    declare_tool!(
        registry,
        allowed_dirs: Arc<RwLock<AllowedDirs>>,
        "rename_file",
        "重命名或移动文件（同一工具覆盖 rename 和 move）。参数：from（源路径）+ to（目标路径）+ overwrite（默认 false，目标存在则拒绝）。同卷走 tokio::fs::rename（原子），跨卷自动降级 copy+remove（非原子，失败回滚删 to 保 from 完整）。须人工批准",
        RiskLevel::Medium,
        schema: object_schema(vec![
            ("from", "string", true),
            ("to", "string", true),
            ("overwrite", "boolean", false),
        ]),
        args => {
            // 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path_with_allowed,
            // 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸
            let snap = allowed_dirs.read().await.clone();
            let from_resolved = resolve_workspace_path_with_allowed(
                args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?,
                &snap,
            )?;
            let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?;
            let to_resolved = resolve_workspace_path_with_allowed(
                args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?,
                &snap,
            )?;
            let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?;
            let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false);

            // 源存在性 + 拒目录(rename 目录语义复杂递归风险大,留 run_command 兜底)
            let from_target = std::path::Path::new(from_path);
            if !from_target.exists() {
                anyhow::bail!("源文件不存在: {}", from_path);
            }
            let from_metadata = tokio::fs::metadata(from_target).await
                .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", from_path, e))?;
            if from_metadata.is_dir() {
                anyhow::bail!("rename_file 仅支持文件，不支持目录: {}", from_path);
            }
            let bytes_moved = from_metadata.len();

            // 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
            let to_target = std::path::Path::new(to_path);
            if let Some(parent) = to_target.parent() {
                // BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
                if !snap.is_authorized(parent) {
                    anyhow::bail!("禁止在项目目录之外创建目录");
                }
                tokio::fs::create_dir_all(parent).await
                    .map_err(|e| anyhow::anyhow!("创建目标父目录失败: {}", e))?;
            }

            // 目标存在性:overwrite=false 直接拒绝(防误覆盖);overwrite=true 先删 to
            if to_target.exists() {
                if !overwrite {
                    anyhow::bail!("目标已存在且 overwrite=false: {}", to_path);
                }
                tokio::fs::remove_file(to_path).await
                    .map_err(|e| anyhow::anyhow!("覆盖删除目标失败: {}", e))?;
            }

            // 同卷:tokio::fs::rename 原子(Windows 走 MoveFileExW UTF-16,中文路径无 GBK 问题)
            // 阶段4:跨卷降级抽统一 helper rename_or_cross_volume_copy(对齐 delete_file .trash 跨盘降级),
            // 消除两处 copy+remove 字面量重复(原 inline 逻辑与此 helper 等价,行为零变更)。
            let cross_volume = rename_or_cross_volume_copy(from_path, to_path).await?;
            Ok(serde_json::json!({
                "from": from_path,
                "to": to_path,
                "renamed": true,
                "bytes_moved": bytes_moved,
                "cross_volume": cross_volume,
            }))
        }
    );

    // ── grep (Low 跨文件内容搜索) ──
    // 缺口补齐:search_files 只搜文件名、read_file 只搜单文件内容,grep 提供 grep -rn 跨文件内容搜索。
    let grep_schema = {
        let mut props = serde_json::Map::new();
        props.insert("pattern".into(), serde_json::json!({ "type": "string", "description": "正则表达式(默认大小写敏感)。无特殊字符时等价字面包含匹配。必填" }));
        props.insert("path".into(), serde_json::json!({ "type": "string", "description": "搜索根目录(锚 workspace + path_auth 授权)。不传时引导用户指定" }));
        props.insert("glob".into(), serde_json::json!({ "type": "string", "description": "可选文件名 glob 过滤(如 *.rs / *.ts),单段匹配;不传搜全部文件" }));
        props.insert("output_mode".into(), serde_json::json!({ "type": "string", "description": "输出模式:content(默认,行级匹配+上下文)/ files_with_matches(仅命中文件名)/ count(每文件命中行数)", "enum": ["content", "files_with_matches", "count"] }));
        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("-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 }));
        serde_json::json!({
            "type": "object",
            "properties": props,
            "required": ["pattern"],
        })
    };
    declare_tool!(
        registry,
        allowed_dirs: Arc<RwLock<AllowedDirs>>,
        "grep",
        "跨文件内容搜索(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,
        schema: grep_schema,
        args => {
            let p = args["path"].as_str().unwrap_or("");
            if p.is_empty() {
                return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录，或直接提供绝对路径。"}));
            }
            let snap = allowed_dirs.read().await.clone();
            let resolved = resolve_workspace_path_with_allowed(p, &snap)?;
            let root = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let pattern = args["pattern"].as_str()
                .ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
            if pattern.is_empty() {
                anyhow::bail!("pattern 不能为空");
            }
            let glob_opt = args.get("glob").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
            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 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 max_results = args.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50).clamp(1, 200) as usize;

            // 编译正则:case_insensitive 开 i flag;失败尝试自动修复常见问题(未分组 | 用 (?:...) 包裹)
            let mut re_builder = regex::RegexBuilder::new(pattern);
            re_builder.case_insensitive(case_insensitive);
            let (re, _auto_fixed) = match re_builder.build() {
                Ok(r) => (r, false),
                Err(e) => {
                    // LLM 常传 `a|b|c` 未分组模式(如 `<template|<div`),尝试 (?:...) 包裹后重试
                    let wrapped = format!("(?:{})", pattern);
                    let mut fix_builder = regex::RegexBuilder::new(&wrapped);
                    fix_builder.case_insensitive(case_insensitive);
                    match fix_builder.build() {
                        Ok(r) => (r, true),
                        Err(_) => anyhow::bail!(
                            "正则编译失败「{}」: {}。提示:多选模式请用 (?:a|b|c) 分组",
                            pattern, e
                        ),
                    }
                }
            };

            // glob 过滤器:编译为 regex 单段匹配(* → [^/]*, ? → [^/], 字面其他字符 escape)。
            // 仅匹配文件名单段(不含 /),对齐 Claude Code grep glob 语义。
            let glob_re = match glob_opt {
                Some(g) => Some(compile_glob_to_regex(g)
                    .map_err(|e| anyhow::anyhow!("glob 编译失败「{}」: {}", g, e))?),
                None => None,
            };

            // 递归遍历+逐文件读+行匹配,收集结果
            let mut matches_out: Vec<FileGrepHit> = Vec::new();
            let mut total: usize = 0;
            let mut truncated = false;
            // BUG-260625-01: path 可能是单文件(grep 命令行原生支持单文件,LLM 常传文件路径)。
            // 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。
            // 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。
            let root_meta = tokio::fs::metadata(root).await
                .map_err(|e| anyhow::anyhow!("无法访问路径 {}: {}", root, e))?;
            if root_meta.is_file() {
                let file_name = std::path::Path::new(root)
                    .file_name()
                    .map(|n| n.to_string_lossy().into_owned())
                    .unwrap_or_default();
                grep_one_file(
                    std::path::Path::new(root), &file_name, &re, glob_re.as_ref(), output_mode,
                    context_lines, max_results, &mut matches_out, &mut total, &mut truncated,
                ).await?;
            } else {
                grep_recursive(
                    root, &re, glob_re.as_ref(), output_mode,
                    context_lines, max_results, 0, 6,
                    &mut matches_out, &mut total, &mut truncated,
                ).await?;
            }

            // output_mode 分派返回结构
            let result = match output_mode {
                "files_with_matches" => {
                    // 仅返命中文件路径列表(去重,顺序保留首次命中)
                    let files: Vec<String> = matches_out.iter()
                        .map(|h| h.file.clone())
                        .collect();
                    serde_json::json!({
                        "path": root,
                        "pattern": pattern,
                        "output_mode": output_mode,
                        "files": files,
                        "total": files.len(),
                        "truncated": truncated,
                    })
                }
                "count" => {
                    // 每文件命中行数
                    let counts: Vec<serde_json::Value> = matches_out.iter()
                        .map(|h| serde_json::json!({ "file": h.file, "count": h.line_matches.len() }))
                        .collect();
                    let total_files = counts.len();
                    serde_json::json!({
                        "path": root,
                        "pattern": pattern,
                        "output_mode": output_mode,
                        "counts": counts,
                        "total": total,
                        "total_files": total_files,
                        "truncated": truncated,
                    })
                }
                _ => {
                    // 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();
                    for hit in &matches_out {
                        for lm in &hit.line_matches {
                            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!({
                                "file": hit.file,
                                "line": line_no,
                                "content": content_field,
                            });
                            if context_lines > 0 && !lm.context.is_empty() {
                                entry["context"] = serde_json::Value::String(lm.context.clone());
                            }
                            lines.push(entry);
                        }
                    }
                    serde_json::json!({
                        "path": root,
                        "pattern": pattern,
                        "output_mode": "content",
                        "matches": lines,
                        "total": total,
                        "truncated": truncated,
                    })
                }
            };
            Ok(result)
        }
    );

    // ── search_files (Low 文件名搜索) ──
    declare_tool!(
        registry,
        allowed_dirs: Arc<RwLock<AllowedDirs>>,
        "search_files",
        "在指定目录下搜索匹配模式（字符串包含匹配）的文件名，支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
        RiskLevel::Low,
        schema: object_schema(vec![("path", "string", false), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
        args => {
            let p = args["path"].as_str().unwrap_or("");
            if p.is_empty() {
                return Ok(serde_json::json!({"error": "请指定搜索目录。可用 @[项目名] 引用已绑定的项目目录，或直接提供绝对路径。"}));
            }
            let snap = allowed_dirs.read().await.clone();
            let resolved = resolve_workspace_path_with_allowed(p, &snap)?;
            let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
            let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
            let recursive = args["recursive"].as_bool().unwrap_or(false);
            let pattern_lower = pattern.to_lowercase();
            const MAX_RESULTS: usize = 50;
            let offset = args["offset"].as_u64().unwrap_or(0) as usize;
            let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize;
            // 先收集全部结果用于 total 计数，再做 skip/take 分页
            let mut all_results = Vec::new();
            let mut total = 0u64;
            // 用较大上限收集全量（分页由内存 skip/take 控制）
            search_files_recursive(path, &pattern_lower, recursive, 0, 5, offset + limit, &mut all_results, &mut total).await?;
            let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
            let has_more = (offset + page_results.len()) < total as usize;
            Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
        }
    );

    // ── run_command (High 零捕获, Arc<()> 占位 capture) ──
    // run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs(放开目录让 AI 在用户任意项目目录闭环)。
    // declare_tool! 宏需 capture 形参,用 Arc<()> 占位(handler body 不引用它)。
    let dummy: Arc<()> = Arc::new(());
    declare_tool!(
        registry,
        dummy: Arc<()>,
        "run_command",
        "在指定工作目录执行 shell 命令，返回 stdout/stderr/exit_code。仅用于命令执行场景：跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file，编辑文件用 patch_file/write_file，列目录用 list_directory，搜索文件名用 search_files——不要用本工具完成这些操作。高风险，须人工批准。命令需自包含（非交互式，避免需用户输入的程序）。默认超时 60 秒。",
        RiskLevel::High,
        schema: object_schema(vec![
            ("command", "string", true),
            ("working_dir", "string", false),
            ("timeout_secs", "integer", false),
        ]),
        args => {
            // BUG-PWSH-BACKSLASH: 早期实现误把 command 当 JSON 字符串做反斜杠转义,导致
            // JSON 解析阶段把 `C:\\foo` 还原为 `C:\foo` 后再次转义丢失反斜杠。现直接 as_str()
            // 取值,不做任何转义处理 —— serde_json 已正确还原反斜杠,PowerShell 也正确接受裸
            // 反斜杠路径(实测 PS5/PS7 均无歧义),根因在 JSON 解析层而非 shell 层。
            let command = args["command"].as_str()
                .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?
                .to_string();
            // working_dir 默认空(由审批弹窗让用户填写),不走 workspace_root 编译期常量。
            // run_command 是 High risk 靠人工审批兜底,不捕获 allowed_dirs。
            let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
                Some(d) => {
                    validate_path(d)?;
                    d.to_string()
                }
                None => String::new(),
            };
            // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
            // clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)
            let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);

            let request = ShellRequest {
                command: command.clone(),
                // 空字符串 → None(空路径是非法 current_dir,Windows 报 os error 123)
                working_dir: if working_dir.is_empty() { None } else { Some(working_dir.clone()) },
                env: HashMap::new(),
                timeout_secs: Some(timeout_secs),
                shell_type: Default::default(),
            };
            // F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。
            // 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
            // 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
            // 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
            // 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
            // 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
            //
            // 任务2: 失败追加 shell 适配提示 —— Windows 默认 PowerShell(PS5 不支持 `&&`),
            // LLM 普遍按 Unix 习惯生成命令,常见失败: `cd .. && cmd`(PS5 拒 `&&`)、
            // 未引用反斜杠路径被解析为转义。提示让 LLM 下次能自行修正(机制优先 prompt 说教)。
            let shell_hint = if cfg!(windows) {
                "\n提示: PowerShell 下路径用正斜杠或双引号包裹(如 \"C:/Users\" 或 \"C:\\Users\"),命令间用 `;` 而非 `&&`(PS5),或改用 pwsh(PS7 支持 `&&`)"
            } else {
                ""
            };
            let result = execute_streaming(request, |kind: StreamKind, line: &str| {
                // 实时流式 emit:每读一行 stdout/stderr 即 emit AiCommandOutput。
                // task-local 未注入(command_stream::scope 未调用)→ emit_output 静默 noop,
                // 不阻断命令(等价原 execute 一次性返回,无副作用)。
                crate::commands::ai::command_stream::emit_output(kind, line);
            }).await.map_err(|e| {
                let msg = e.to_string();
                if msg.contains("命令执行超时") {
                    anyhow::anyhow!(
                        "命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。{}",
                        timeout_secs, shell_hint
                    )
                } else {
                    anyhow::anyhow!("{}{}", msg, shell_hint)
                }
            })?;

            // 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
            const MAX_OUT: usize = 10_000;
            let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT);
            let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);

            Ok(serde_json::json!({
                "command": command,
                "working_dir": working_dir,
                "exit_code": result.exit_code,
                "duration_ms": result.duration_ms,
                "stdout": stdout,
                "stderr": stderr,
                "truncated": stdout_trunc || stderr_trunc,
            }))
        }
    );

    // ── detect_environment (Low 零捕获, Arc<()> 占位 capture, L1 agent 元能力层) ──
    // 返回 JSON:{ os, default_shell, python_path, node_path }。
    let dummy_env: Arc<()> = Arc::new(());
    declare_tool!(
        registry,
        dummy_env: Arc<()>,
        "detect_environment",
        "探测当前运行环境,返回 { os, default_shell, python_path, node_path, errors }。os=Windows/macOS/Linux;default_shell=Windows 默认 PowerShell / Unix 默认 bash(对齐 df-execute ShellType::default);python_path/node_path 为运行时探测到的可执行路径(which/where),探测失败或未安装为 null。只读无副作用。建议在执行 python/node 命令或环境相关操作前主动调用,避免引用不存在的解释器或用错 shell。LLM 可主动调用;命令执行断路器触发后系统提示会引导调用(失败自愈补救式感知)",
        RiskLevel::Low,
        schema: object_schema(vec![]),
        args => {
            // 静态 OS:cfg! 编译期分支,跨设备各自正确(Win/macOS/Linux/Unknown)
            let os = if cfg!(target_os = "windows") {
                "Windows"
            } else if cfg!(target_os = "macos") {
                "macOS"
            } else if cfg!(target_os = "linux") {
                "Linux"
            } else {
                "Unknown"
            };
            // 默认 shell:对齐 df_execute::shell::ShellType::default(Windows=PowerShell, Unix=Sh=bash)。
            // 不直接 import ShellType(避免 dep 漂移),用同样的 cfg! 逻辑保持单一语义源。
            // 注:Unix 上 ShellType::Sh 实际执行 sh,AI 视角写 bash 兼容脚本即可(POSIX 子集)。
            let default_shell = if cfg!(target_os = "windows") {
                "PowerShell"
            } else {
                "bash"
            };

            // 开关 env_probe_enabled(设计 §8):off 则只返静态 profile(无 python_path/node_path 探测),
            // 兜底降级旧行为(等于 env_profile_line 静态注入的运行时版本)。默认 on。
            let probe_enabled = match std::env::var("DEVFLOW_ENV_PROBE_ENABLED") {
                Ok(v) => !matches!(v.trim().to_lowercase().as_str(), "off" | "0" | "false" | "no"),
                Err(_) => true, // 未设 = 默认开
            };

            let mut errors: Vec<String> = Vec::new();
            let (python_path, node_path) = if probe_enabled {
                // Windows 用 where,Unix 用 which。探测命令在 PATH 中不可用或解释器未装时返 null + 记错。
                // probe_executable 内置 8s 超时兜底(防 which 卡死拖垮会话),失败记 error 不阻断。
                // Unix python:python3 优先(对齐 env_profile_line 语义),无则试 python(记错降级,不噪音)。
                let py = if cfg!(target_os = "windows") {
                    probe_executable("python", "where python", &mut errors).await
                } else {
                    let p3 = probe_executable("python3", "which python3", &mut errors).await;
                    if p3.is_some() {
                        p3
                    } else {
                        // python3 探测失败的 errors 已记入主 errors;python 再探,失败记错(两解释器都无才全 null)。
                        probe_executable("python", "which python", &mut errors).await
                    }
                };
                let node = if cfg!(target_os = "windows") {
                    probe_executable("node", "where node", &mut errors).await
                } else {
                    probe_executable("node", "which node", &mut errors).await
                };
                (py, node)
            } else {
                // 开关关:探测降级,两字段 null(等于不探测)。errors 不记(非失败,是开关显式关闭)。
                (None, None)
            };

            Ok(serde_json::json!({
                "os": os,
                "default_shell": default_shell,
                "python_path": python_path,
                "node_path": node_path,
                "probe_enabled": probe_enabled,
                "errors": errors,
            }))
        }
    );
}

#[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
// 仅 file 层 detect_environment handler 引用 → 改 pub(crate),本模块 use 引入(见上方 import)。
