use std::collections::HashSet;
use std::fs;
use std::path::Path;
use crate::cm_tools::cargo_metadata::cargo_metadata_command;
use crate::cm_tools::project_metrics;
const PROFILE_MARKDOWN_VERSION: u32 = 1;
pub fn build_project_profile_markdown(workspace_root: &Path, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
let mut sections: Vec<String> = Vec::new();
sections.push(format!(
"## CrabMate 项目画像(自动生成 v{})\n",
PROFILE_MARKDOWN_VERSION
));
sections.push("_由服务端只读扫描生成,不含密钥;切换工作区或点击刷新可更新。_\n".to_string());
if let Some(block) = section_layout(workspace_root) {
sections.push(block);
}
if let Some(block) = section_top_dirs(workspace_root) {
sections.push(block);
}
if let Some(block) = section_code_stats(workspace_root) {
sections.push(block);
}
if let Some(block) = section_cargo_metadata(workspace_root) {
sections.push(block);
}
if let Some(block) = section_package_json(workspace_root) {
sections.push(block);
}
if let Some(block) = section_python_hints(workspace_root) {
sections.push(block);
}
sections.push("\n### 约定与备忘\n".to_string());
sections.push(
"详细约定请写在 `.crabmate/agent_memory.md`(若已启用 `agent_memory_file`)或由团队在仓库文档中维护。\n"
.to_string(),
);
let mut out = sections.join("\n");
if out.chars().count() > max_chars {
let truncated: String = out.chars().take(max_chars).collect();
out = format!(
"{truncated}\n\n[... 项目画像过长,已按 project_profile_inject_max_chars 截断 ...]"
);
}
out
}
fn workspace_member_crate_names(ws: &toml::Value) -> Option<Vec<String>> {
let members = ws.get("members")?.as_array()?;
let mut names: Vec<String> = Vec::new();
for m in members.iter().filter_map(|x| x.as_str()) {
let trimmed = m.trim_end_matches(['/', '\\']);
let name = trimmed
.rsplit(['/', '\\'])
.next()
.unwrap_or(trimmed)
.to_string();
if !name.is_empty() && name != "*" {
names.push(name);
}
}
names.sort();
names.dedup();
Some(names)
}
fn format_workspace_member_lines(names: Vec<String>) -> Vec<String> {
if names.is_empty() {
return vec!["- (workspace 未列出 members 或为空)\n".to_string()];
}
let show: Vec<_> = names.iter().take(16).cloned().collect();
let mut lines = vec![format!(
"- 成员 crate 目录(节选):{}\n",
show.join("、")
)];
if names.len() > 16 {
lines.push(format!("- … 共 {} 个 member 条目\n", names.len()));
}
lines
}
fn section_layout_kind_lines(v: &toml::Value) -> Vec<String> {
if let Some(ws) = v.get("workspace") {
let mut lines = vec!["**Rust workspace**\n".to_string()];
match workspace_member_crate_names(ws) {
Some(names) => lines.extend(format_workspace_member_lines(names)),
None => lines.push("- (未解析到 `[workspace].members`)\n".to_string()),
}
lines
} else if v.get("package").is_some() {
let name = v
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("unknown");
vec![format!("**Rust 单包(cargo)**\n- 包名:`{name}`\n")]
} else {
vec!["**检测到 Cargo.toml(结构未识别为 package 或 workspace)**\n".to_string()]
}
}
fn section_layout(root: &Path) -> Option<String> {
let cargo = root.join("Cargo.toml");
if !cargo.is_file() {
return None;
}
let raw = fs::read_to_string(&cargo).ok()?;
let v: toml::Value = toml::from_str(&raw).ok()?;
let mut lines = vec!["### 工程类型\n".to_string()];
lines.extend(section_layout_kind_lines(&v));
Some(lines.join(""))
}
fn section_top_dirs(root: &Path) -> Option<String> {
let mut entries: Vec<String> = Vec::new();
let rd = fs::read_dir(root).ok()?;
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let ft = e.file_type().ok()?;
let label = if ft.is_dir() {
format!("{name}/")
} else {
name
};
entries.push(label);
}
entries.sort();
if entries.is_empty() {
return None;
}
let mut out = String::from("### 顶层条目(节选)\n");
for e in entries.iter().take(24) {
out.push_str(&format!("- `{e}`\n"));
}
if entries.len() > 24 {
out.push_str(&format!("- … 共 {} 项\n", entries.len()));
}
Some(out)
}
fn section_code_stats(root: &Path) -> Option<String> {
let stats =
project_metrics::gather_workspace_code_stats(root, project_metrics::DEFAULT_EXCLUDED_DIRS);
if stats.languages.is_empty() {
return Some(format!(
"### 语言与规模({})\n- (未识别到源码文件)\n",
project_metrics::profile_stats_heading_suffix()
));
}
let total_code = stats.total_code();
let total_files = stats.total_files();
let mut out = format!(
"### 语言与规模({})\n",
project_metrics::profile_stats_heading_suffix()
);
out.push_str(&format!(
"- 估算代码行数:**{}**({} 个文件)\n",
total_code, total_files
));
let mut labels: Vec<String> = Vec::new();
for lang in stats.languages.iter().take(8) {
let pct = lang
.code
.saturating_mul(100)
.checked_div(total_code.max(1))
.unwrap_or(0);
labels.push(format!("{} {}%", lang.language, pct));
}
out.push_str(&format!(
"- 主要语言占比(按代码行):{}\n",
labels.join(",")
));
Some(out)
}
fn section_cargo_metadata(root: &Path) -> Option<String> {
if !root.join("Cargo.toml").is_file() {
return None;
}
let output = match cargo_metadata_command(root, true, 1).output() {
Ok(o) => o,
Err(_) => {
return Some(
"### Cargo 依赖(metadata)\n- (无法执行 `cargo metadata`,已跳过)\n".to_string(),
);
}
};
if !output.status.success() {
return Some(
"### Cargo 依赖(metadata)\n- (`cargo metadata --no-deps` 失败,已跳过)\n"
.to_string(),
);
}
let val: serde_json::Value = match serde_json::from_slice(&output.stdout) {
Ok(v) => v,
Err(_) => {
return Some(
"### Cargo 依赖(metadata)\n- (输出 JSON 解析失败,已跳过)\n".to_string(),
);
}
};
let Some(packages) = val.get("packages").and_then(|p| p.as_array()) else {
return Some("### Cargo 依赖(metadata)\n- (无 packages 字段,已跳过)\n".to_string());
};
let workspace_ids: HashSet<String> = val
.get("workspace_members")
.and_then(|w| w.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
let mut ws_names: Vec<String> = Vec::new();
for p in packages {
let Some(id) = p.get("id").and_then(|i| i.as_str()) else {
continue;
};
if !workspace_ids.contains(id) {
continue;
}
let Some(name) = p.get("name").and_then(|n| n.as_str()) else {
continue;
};
ws_names.push(name.to_string());
}
ws_names.sort();
ws_names.dedup();
let dep_count = val
.get("resolve")
.and_then(|r| r.get("root"))
.and_then(|rid| rid.as_str())
.and_then(|root_id| {
packages
.iter()
.find(|p| p.get("id").and_then(|i| i.as_str()) == Some(root_id))
})
.and_then(|p| p.get("dependencies"))
.and_then(|d| d.as_array())
.map(|a| a.len())
.unwrap_or(0);
let mut out = String::from("### Cargo 依赖(metadata,--no-deps)\n");
if !ws_names.is_empty() {
let show: Vec<_> = ws_names.iter().take(20).cloned().collect();
out.push_str(&format!("- workspace 包(节选):{}\n", show.join("、")));
if ws_names.len() > 20 {
out.push_str(&format!("- … 共 {} 个包\n", ws_names.len()));
}
}
out.push_str(&format!(
"- 根包声明的直接依赖数:**{dep_count}**(含 target 与 feature 条目,仅供参考)\n"
));
Some(out)
}
fn read_package_json_summary(path: &Path, root: &Path) -> Option<String> {
let raw = fs::read_to_string(path).ok()?;
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
let name = v.get("name").and_then(|n| n.as_str()).unwrap_or("(未命名)");
let mut dep_n = 0usize;
for key in [
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
] {
if let Some(o) = v.get(key).and_then(|x| x.as_object()) {
dep_n += o.len();
}
}
let label = path
.strip_prefix(root)
.ok()
.map(|p| p.display().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "package.json".to_string());
Some(format!(
"- `{label}`:npm 包 **{name}**,声明依赖条目约 **{dep_n}** 个\n"
))
}
fn section_package_json(root: &Path) -> Option<String> {
let mut blocks: Vec<String> = Vec::new();
let root_pj = root.join("package.json");
if root_pj.is_file()
&& let Some(line) = read_package_json_summary(&root_pj, root)
{
blocks.push(line);
}
if blocks.is_empty() {
return None;
}
let mut out = String::from("### Node / 前端(package.json)\n");
out.push_str(&blocks.join(""));
Some(out)
}
fn section_python_hints(root: &Path) -> Option<String> {
let mut hints: Vec<String> = Vec::new();
if root.join("pyproject.toml").is_file() {
hints.push("- 存在 `pyproject.toml`(Python 项目)\n".to_string());
}
if root.join("requirements.txt").is_file() {
hints.push("- 存在 `requirements.txt`\n".to_string());
}
if root.join("uv.lock").is_file() {
hints.push("- 存在 `uv.lock`(uv)\n".to_string());
}
if hints.is_empty() {
return None;
}
let mut out = String::from("### Python(线索)\n");
out.push_str(&hints.join(""));
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn profile_includes_rust_and_counts() {
let root = std::env::temp_dir().join(format!(
"crabmate_project_profile_test_{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let mut f = std::fs::File::create(root.join("Cargo.toml")).unwrap();
writeln!(
f,
r#"
[package]
name = "demo_prof"
version = "0.1.0"
edition = "2021"
"#
)
.unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
let mut main_rs = std::fs::File::create(root.join("src/lib.rs")).unwrap();
writeln!(main_rs, "pub fn f() {{}}").unwrap();
let md = build_project_profile_markdown(&root, 20_000);
assert!(md.contains("demo_prof"));
assert!(md.contains("Rust"));
#[cfg(feature = "project_metrics")]
assert!(md.contains("tokei"));
#[cfg(not(feature = "project_metrics"))]
assert!(md.contains("内置扩展名统计"));
let _ = std::fs::remove_dir_all(&root);
}
}