use std::fs;
use std::path::{Path, PathBuf};
use super::ToolContext;
use super::file::canonical_workspace_root;
use super::output_util::truncate_output_bytes;
fn rel_path_ok(workspace_root: &Path, rel: &str) -> Option<PathBuf> {
let rel = rel.trim();
if rel.is_empty() || Path::new(rel).is_absolute() || rel.contains("..") {
return None;
}
let joined = workspace_root.join(rel);
let ws = canonical_workspace_root(workspace_root).ok()?;
let canon = joined.canonicalize().ok()?;
if !canon.starts_with(&ws) {
return None;
}
Some(canon)
}
fn read_head_lines(path: &Path, max_lines: usize) -> Option<String> {
let raw = fs::read_to_string(path).ok()?;
let take = max_lines.max(1);
let mut out = String::new();
for (i, line) in raw.lines().take(take).enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(line);
}
Some(out)
}
fn collect_matching_lines(path: &Path, keywords: &[&str], max_hits: usize) -> Option<String> {
let raw = fs::read_to_string(path).ok()?;
let mut hits = Vec::new();
for line in raw.lines() {
if keywords.iter().any(|k| line.contains(k)) {
hits.push(line.trim_end());
if hits.len() >= max_hits {
break;
}
}
}
if hits.is_empty() {
return None;
}
Some(hits.join("\n"))
}
fn section_file(
workspace_root: &Path,
title: &str,
rel: &str,
head_lines: usize,
keywords: &[&str],
keyword_hits: usize,
out: &mut String,
) {
out.push_str(&format!("### {title}\n"));
out.push_str(&format!("路径:`{rel}`\n\n"));
let Some(canon) = rel_path_ok(workspace_root, rel) else {
out.push_str("(跳过:路径非法、不存在或越出工作区。)\n\n");
return;
};
if !canon.is_file() {
out.push_str("(跳过:非文件或不存在。)\n\n");
return;
}
if let Some(head) = read_head_lines(&canon, head_lines) {
out.push_str("**文件头预览**\n```\n");
out.push_str(&head);
out.push_str("\n```\n\n");
}
if !keywords.is_empty() {
if let Some(block) = collect_matching_lines(&canon, keywords, keyword_hits) {
out.push_str("**关键字匹配行(节选)**\n```\n");
out.push_str(&block);
out.push_str("\n```\n\n");
} else {
out.push_str("**关键字匹配行**:无匹配(可扩大检索或手工 `read_file`)。\n\n");
}
}
}
pub fn crate_contract_map(args_json: &str, ctx: &ToolContext<'_>) -> String {
let v = match crate::cm_tools::tools::parse_args_json(args_json) {
Ok(v) => v,
Err(e) => return e,
};
let head_lines = v
.get("head_lines_per_file")
.and_then(|n| n.as_u64())
.map(|n| n as usize)
.unwrap_or(24)
.clamp(5, 120);
let keyword_hits = v
.get("keyword_hits_per_file")
.and_then(|n| n.as_u64())
.map(|n| n as usize)
.unwrap_or(40)
.clamp(5, 200);
let extra_paths: Vec<String> = v
.get("extra_paths")
.and_then(|x| x.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(|s| s.trim().to_string()))
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
let max_extra = v
.get("max_extra_paths")
.and_then(|n| n.as_u64())
.map(|n| n as usize)
.unwrap_or(12)
.clamp(0, 40);
let mut out = String::new();
out.push_str("=== crate_contract_map(只读契约地图)===\n\n");
out.push_str("schema: `crabmate_contract_map_v1`\n\n");
out.push_str(
"说明:本节仅列出**工作区内**与 HTTP/SSE/配置/前后端对齐相关的**锚点文件**及短预览;\
**不包含密钥**;**不调用**外部命令。复杂改动请继续 `read_file` / `search_in_files` / `repo_overview_sweep`。\n\n",
);
out.push_str("## 1) 后端路由与入口(Rust)\n\n");
section_file(
ctx.working_dir,
"Axum 入口与路由(常见挂载点)",
"src/lib.rs",
head_lines,
&["Router", ".route(", "merge(", "/chat", "serve"],
keyword_hits,
&mut out,
);
out.push_str("## 2) SSE / 聊天协议(双端对齐)\n\n");
section_file(
ctx.working_dir,
"SSE 协议文档(权威)",
"docs/SSE协议.md",
head_lines,
&["错误", "code", "SSE", "协议"],
keyword_hits.min(30),
&mut out,
);
out.push_str(
"官方 UI SSE 解析在 Client 仓 `../crabmate-client/frontend/src/api/`(`chat_stream/parser_v2.rs`、`sse_dispatch/types.rs`)。本工作区无 `frontend/` 源码;下列为本仓协议与金样。\n\n",
);
section_file(
ctx.working_dir,
"Rust 控制面分类(IM/V1 形状)",
"src/cm_sse_protocol/control_classify.rs",
head_lines,
&["classify", "handled", "plain", "stop"],
keyword_hits.min(40),
&mut out,
);
section_file(
ctx.working_dir,
"SSE AG-UI 金样(Web 回归)",
"fixtures/sse_ag_ui_golden.jsonl",
head_lines.min(40),
&["CUSTOM", "handled", "plain"],
20,
&mut out,
);
section_file(
ctx.working_dir,
"SSE 控制面金样(V1/IM 参考)",
"fixtures/sse_control_golden.jsonl",
head_lines.min(40),
&["plain", "handled", "stop"],
20,
&mut out,
);
out.push_str("## 3) 配置与运维入口\n\n");
section_file(
ctx.working_dir,
"默认 Agent 配置(键入口)",
"config/default_config.toml",
head_lines,
&["[", "staged", "tool", "web_", "llm_"],
keyword_hits,
&mut out,
);
section_file(
ctx.working_dir,
"配置说明(用户可见键与 CM_ 变量)",
"docs/配置说明.md",
head_lines,
&["CM_", "环境", "变量", "默认"],
keyword_hits.min(35),
&mut out,
);
section_file(
ctx.working_dir,
"命令行与子命令 / 路由索引",
"docs/命令行与路由.md",
head_lines,
&["serve", "POST", "GET", "/chat"],
keyword_hits.min(35),
&mut out,
);
if !extra_paths.is_empty() {
out.push_str("## 4) 调用方附加路径\n\n");
for rel in extra_paths.iter().take(max_extra) {
section_file(
ctx.working_dir,
rel.as_str(),
rel.as_str(),
head_lines,
&[],
0,
&mut out,
);
}
if extra_paths.len() > max_extra {
out.push_str(&format!(
"\n(仅处理前 {} 条 `extra_paths`,共 {} 条。)\n",
max_extra,
extra_paths.len()
));
}
}
truncate_output_bytes(&out, ctx.command_max_output_len)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn contract_map_minimal_workspace() {
let root =
std::env::temp_dir().join(format!("crabmate_contract_map_{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("src")).expect("mkdir");
fs::write(
root.join("src/lib.rs"),
"use axum::Router;\nfn x() { let _ = Router::new().route(\"/chat\", get(|| async {})); }\n",
)
.expect("write");
fs::create_dir_all(root.join("docs")).expect("mkdir");
fs::write(root.join("docs/SSE协议.md"), "# SSE\n\n错误码 LLM_\n").expect("write");
fs::create_dir_all(root.join("config")).expect("mkdir");
fs::write(root.join("config/default_config.toml"), "[agent]\nx = 1\n").expect("write");
let ctx = ToolContext {
cfg: None,
codebase_semantic_host: None,
command_max_output_len: 30_000,
weather_timeout_secs: 0,
allowed_commands: &[],
working_dir: &root,
web_search_timeout_secs: 0,
web_search_provider: crate::cm_config::WebSearchProvider::Brave,
web_search_api_key: "",
web_search_max_results: 0,
http_fetch_allowed_prefixes: &[],
http_fetch_timeout_secs: 0,
http_fetch_max_response_bytes: 0,
command_timeout_secs: 30,
read_file_turn_cache: None,
workspace_changelist: None,
test_result_cache_enabled: false,
test_result_cache_max_entries: 8,
long_term_memory_host: None,
};
let body = crate_contract_map(
r#"{"extra_paths":["src/lib.rs","docs/SSE协议.md","config/default_config.toml"]}"#,
&ctx,
);
let _ = fs::remove_dir_all(&root);
assert!(body.contains("crate_contract_map"));
assert!(body.contains("crabmate_contract_map_v1"));
assert!(body.contains("Client 仓"));
assert!(body.contains("Router"));
assert!(body.contains("SSE"));
}
}