use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::cm_tools::project_profile;
use super::parse::RepoSweepParams;
use crate::cm_tools::tools::ToolContext;
use crate::cm_tools::tools::file::{canonical_workspace_root, glob_files, list_tree, read_file};
use crate::cm_tools::tools::output_util::truncate_output_bytes;
use crate::cm_tools::workspace::path::{
absolutize_relative_under_root, ensure_existing_ancestor_within_root,
};
enum RelPathCheck {
Ok(PathBuf),
Invalid,
OutOfBounds,
}
fn classify_rel_path(workspace_root: &Path, rel: &str) -> RelPathCheck {
let rel = rel.trim();
if rel.is_empty() || Path::new(rel).is_absolute() || rel.contains("..") {
return RelPathCheck::Invalid;
}
let ws = match canonical_workspace_root(workspace_root) {
Ok(p) => p,
Err(_) => return RelPathCheck::Invalid,
};
let normalized = match absolutize_relative_under_root(&ws, rel) {
Ok(p) => p,
Err(e) if e.is_policy_denied() => return RelPathCheck::OutOfBounds,
Err(_) => return RelPathCheck::Invalid,
};
if ensure_existing_ancestor_within_root(&ws, &normalized).is_err() {
return RelPathCheck::OutOfBounds;
}
RelPathCheck::Ok(normalized)
}
fn rel_path_safe(workspace_root: &Path, rel: &str) -> Option<PathBuf> {
match classify_rel_path(workspace_root, rel) {
RelPathCheck::Ok(p) => Some(p),
_ => None,
}
}
fn tool_ctx_stub<'a>(workspace_root: &'a Path, max_output_len: usize) -> ToolContext<'a> {
ToolContext {
cfg: None,
codebase_semantic_host: None,
command_max_output_len: max_output_len,
weather_timeout_secs: 0,
allowed_commands: &[],
working_dir: workspace_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,
}
}
fn sweep_append_project_profile(
out: &mut String,
workspace_root: &Path,
section: &mut usize,
params: &RepoSweepParams,
) {
if !params.include_project_profile || params.project_profile_max_chars == 0 {
return;
}
out.push_str(&format!("## {section}) 项目画像(自动生成,只读扫描)\n\n"));
out.push_str(&project_profile::build_project_profile_markdown(
workspace_root,
params.project_profile_max_chars,
));
out.push_str("\n---\n\n");
*section += 1;
}
fn sweep_append_doc_previews(
out: &mut String,
workspace_root: &Path,
ctx: &ToolContext<'_>,
section: &mut usize,
params: &RepoSweepParams,
) {
out.push_str(&format!("## {section}) 主文档预览(每文件前若干行)\n\n"));
*section += 1;
for rel in ¶ms.doc_paths {
let Some(canon) = rel_path_safe(workspace_root, rel) else {
let msg = match classify_rel_path(workspace_root, rel) {
RelPathCheck::OutOfBounds => format!("- `{}`:跳过(路径越界)\n", rel),
RelPathCheck::Invalid | RelPathCheck::Ok(_) => {
format!("- `{}`:跳过(路径非法)\n", rel)
}
};
out.push_str(&msg);
continue;
};
if !canon.is_file() {
out.push_str(&format!("- `{}`:不存在或非文件,跳过\n", rel));
continue;
}
let args = serde_json::json!({
"path": rel,
"start_line": 1,
"max_lines": params.doc_preview_max_lines,
"encoding": "utf-8"
});
let args_s = match serde_json::to_string(&args) {
Ok(s) => s,
Err(e) => {
out.push_str(&format!("- `{}`:序列化 read_file 参数失败:{}\n", rel, e));
continue;
}
};
let body = read_file(&args_s, workspace_root, ctx);
out.push_str(&format!("### `{}`\n", rel));
out.push_str(&body);
out.push_str("\n---\n\n");
}
}
fn sweep_append_source_trees(
out: &mut String,
workspace_root: &Path,
section: &mut usize,
params: &RepoSweepParams,
) {
out.push_str(&format!("## {section}) 源码/结构目录树(list_tree)\n\n"));
*section += 1;
for root in ¶ms.source_roots {
let Some(canon) = rel_path_safe(workspace_root, root) else {
let msg = match classify_rel_path(workspace_root, root) {
RelPathCheck::OutOfBounds => format!("### `{}`\n路径越界,跳过\n\n", root),
RelPathCheck::Invalid | RelPathCheck::Ok(_) => {
format!("### `{}`\n路径非法,跳过\n\n", root)
}
};
out.push_str(&msg);
continue;
};
if !canon.is_dir() {
out.push_str(&format!("### `{}`\n目录不存在,跳过\n\n", root));
continue;
}
let tree_args = serde_json::json!({
"path": root,
"max_depth": params.list_tree_max_depth,
"max_entries": params.list_tree_max_entries,
"include_hidden": params.list_include_hidden
});
let tree_s = match serde_json::to_string(&tree_args) {
Ok(s) => s,
Err(e) => {
out.push_str(&format!("### `{}`\n序列化 list_tree 失败:{}\n\n", root, e));
continue;
}
};
out.push_str(&format!("### 根:`{}`\n", root));
out.push_str(&list_tree(&tree_s, workspace_root));
out.push_str("\n\n");
}
}
fn sweep_collect_build_paths(workspace_root: &Path, params: &RepoSweepParams) -> BTreeSet<String> {
let mut found: BTreeSet<String> = BTreeSet::new();
for pattern in ¶ms.build_globs {
let gargs = serde_json::json!({
"pattern": pattern,
"path": ".",
"max_depth": params.build_glob_max_depth,
"max_results": params.build_glob_max_results,
"include_hidden": false
});
let gstr = match serde_json::to_string(&gargs) {
Ok(s) => s,
Err(_) => continue,
};
let block = glob_files(&gstr, workspace_root);
let mut after_first_sep = false;
for line in block.lines() {
let t = line.trim();
if t == "---" {
if !after_first_sep {
after_first_sep = true;
} else {
break;
}
continue;
}
if !after_first_sep {
continue;
}
if t.starts_with("匹配 ") {
break;
}
if !t.is_empty() {
found.insert(t.to_string());
}
}
}
found
}
fn sweep_append_build_section(
out: &mut String,
workspace_root: &Path,
section: &mut usize,
params: &RepoSweepParams,
) {
out.push_str(&format!(
"## {section}) 构建与清单文件(glob 汇总,去重排序)\n\n"
));
*section += 1;
let found = sweep_collect_build_paths(workspace_root, params);
if found.is_empty() {
out.push_str("(未匹配到常见构建/清单文件;可检查 build_globs 或仓库布局。)\n\n");
} else {
for p in found.iter().take(500) {
out.push_str(p);
out.push('\n');
}
if found.len() > 500 {
out.push_str(&format!("\n… 共 {} 条路径,仅列出前 500 条\n", found.len()));
}
out.push('\n');
}
}
fn sweep_append_conclusion_prompt(out: &mut String, section: usize) {
out.push_str(&format!(
"## {section}) 请模型据此撰写的分析结论(提纲)\n\n"
));
out.push_str(
"请用自然语言输出结构化结论,建议包含:\n\
- **项目定位**:本仓库解决什么问题、主要用户/运行形态(依据 README/AGENTS 等)。\n\
- **技术栈与入口**:语言、主程序入口、Web/CLI 等(依据文档与目录树)。\n\
- **模块/分层**:`src/`(或其它根)下大致分层与职责(依据树与 DEVELOPMENT 等)。\n\
- **构建与质量**:如何从清单文件推断构建、测试、CI(依据第 3 节路径 + 文档)。\n\
- **风险与缺口**:文档未覆盖处、单测/类型检查是否可从路径推断、后续建议深挖路径。\n\n",
);
}
pub fn repo_overview_sweep(
args_json: &str,
workspace_root: &Path,
max_output_len: usize,
) -> String {
let v = match crate::cm_tools::tools::parse_args_json(args_json) {
Ok(v) => v,
Err(e) => return e,
};
let params = RepoSweepParams::from_json(&v);
let ctx = tool_ctx_stub(workspace_root, max_output_len);
let mut out = String::new();
out.push_str("=== repo_overview_sweep(只读聚合)===\n");
out.push_str("说明:下列为自动收集的仓库骨架材料;**分析结论须由模型根据本节内容在对话中撰写**,本工具不代替推理。\n\n");
let mut section = 1usize;
sweep_append_project_profile(&mut out, workspace_root, &mut section, ¶ms);
sweep_append_doc_previews(&mut out, workspace_root, &ctx, &mut section, ¶ms);
sweep_append_source_trees(&mut out, workspace_root, &mut section, ¶ms);
sweep_append_build_section(&mut out, workspace_root, &mut section, ¶ms);
sweep_append_conclusion_prompt(&mut out, section);
truncate_output_bytes(&out, max_output_len)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn sweep_minimal_workspace() {
let root =
std::env::temp_dir().join(format!("crabmate_repo_overview_{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("src")).expect("mkdir");
fs::write(root.join("README.md"), "# T\n\nhello\n").expect("write");
fs::write(root.join("src/lib.rs"), "// x\n").expect("write");
fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
)
.expect("write");
let out = repo_overview_sweep("{}", &root, 50_000);
let _ = fs::remove_dir_all(&root);
assert!(out.contains("README.md"));
assert!(out.contains("hello"));
assert!(out.contains("Cargo.toml"));
assert!(out.contains("repo_overview_sweep"));
assert!(out.contains("请模型据此撰写"));
assert!(out.contains("项目画像"));
assert!(out.contains("工程类型") || out.contains("Rust"));
let root2 =
std::env::temp_dir().join(format!("crabmate_repo_overview2_{}", std::process::id()));
let _ = fs::remove_dir_all(&root2);
fs::create_dir_all(root2.join("src")).expect("mkdir");
fs::write(root2.join("README.md"), "# T\n").expect("write");
fs::write(root2.join("src/lib.rs"), "// x\n").expect("write");
fs::write(
root2.join("Cargo.toml"),
"[package]\nname = \"y\"\nversion = \"0.1.0\"\n",
)
.expect("write");
let out2 = repo_overview_sweep(r#"{"include_project_profile":false}"#, &root2, 50_000);
let _ = fs::remove_dir_all(&root2);
assert!(!out2.contains("CrabMate 项目画像"));
}
#[test]
fn sweep_reports_missing_paths_without_out_of_bounds_label() {
let root = std::env::temp_dir().join(format!(
"crabmate_repo_overview_missing_{}",
std::process::id()
));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&root).expect("mkdir");
fs::write(root.join("README.md"), "# T\n").expect("write");
let out = repo_overview_sweep(
r#"{"doc_paths":["AGENTS.md"],"source_roots":["src"],"include_project_profile":false}"#,
&root,
50_000,
);
let _ = fs::remove_dir_all(&root);
assert!(out.contains("AGENTS.md") && out.contains("不存在"));
assert!(out.contains("src") && out.contains("目录不存在"));
assert!(!out.contains("路径非法或越界"));
assert!(!out.contains("路径越界"));
}
}