重构: df-ai-core trait下沉拆crate+导入历史项目批量扫描

This commit is contained in:
2026-06-16 20:19:55 +08:00
parent d00b30f0ba
commit 2069f79198
19 changed files with 1518 additions and 311 deletions

View File

@@ -92,6 +92,157 @@ pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
Ok(stack)
}
// ============================================================
// 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)
// ============================================================
/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx)
const MONOREPO_MARKERS: &[&str] = &[
"pnpm-workspace.yaml",
"lerna.json",
"turbo.json",
"nx.json",
];
/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。
///
/// 命中任一即视为 monorepo:
/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在
/// - package.json 含 `workspaces` 字段(npm/yarn workspaces)
pub fn is_monorepo(root: &Path) -> bool {
if !root.is_dir() {
return false;
}
for marker in MONOREPO_MARKERS {
if root.join(marker).is_file() {
return true;
}
}
// package.json workspaces 字段(npm/yarn)
let pkg_path = root.join("package.json");
if pkg_path.is_file() {
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
if pkg.get("workspaces").is_some() {
return true;
}
}
}
}
false
}
/// 单个发现的候选项目(monorepo 子项目或独立项目)
#[derive(Debug, Clone)]
pub struct DiscoveredProject {
/// 项目根目录绝对路径
pub path: String,
/// 推断的项目名(目录名)
pub name: String,
/// 规则探测的技术栈(空=未识别)
pub stack: Vec<String>,
/// 是否为 monorepo 根(便于前端标记)
pub is_monorepo: bool,
}
/// 在指定根目录下发现候选项目。
///
/// 策略(只展开一层,不做深递归):
/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目
/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空)
/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目
///
/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。
pub fn discover_projects(root: &Path) -> Result<Vec<DiscoveredProject>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut out: Vec<DiscoveredProject> = Vec::new();
let mono = is_monorepo(root);
// 1. 根目录自身是项目(有 manifest 标志)
if has_project_manifest(root) {
let stack = detect_stack(root).unwrap_or_default();
out.push(DiscoveredProject {
path: root.to_string_lossy().to_string(),
name: root
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| root.to_string_lossy().to_string()),
stack,
is_monorepo: mono,
});
}
// 2. monorepo → 展开 packages/* apps/* 直接子目录
// 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选
let scan_globs: &[&str] = if mono {
&["packages", "apps"]
} else {
&[""]
};
for glob in scan_globs {
let target = if glob.is_empty() {
root.to_path_buf()
} else {
root.join(glob)
};
if !target.is_dir() {
continue;
}
let Ok(entries) = std::fs::read_dir(&target) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if !p.is_dir() {
continue;
}
let name = e.file_name().to_string_lossy().to_string();
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') {
continue;
}
// 必须有项目标志 + detect_stack 非空
if !has_project_manifest(&p) {
continue;
}
let stack = match detect_stack(&p) {
Ok(s) => s,
Err(_) => continue,
};
if stack.is_empty() {
continue;
}
out.push(DiscoveredProject {
path: p.to_string_lossy().to_string(),
name,
stack,
is_monorepo: false,
});
}
}
Ok(out)
}
/// 目录是否含任一项目清单标志文件
fn has_project_manifest(dir: &Path) -> bool {
const MARKS: &[&str] = &[
"Cargo.toml",
"package.json",
"go.mod",
"pyproject.toml",
"requirements.txt",
"pom.xml",
"build.gradle",
"build.gradle.kts",
];
MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj")
}
/// 解析 package.json,合并 dependencies + devDependencies 的包名
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
let content = std::fs::read_to_string(path.as_ref())
@@ -132,8 +283,8 @@ fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
pub fn extract_description(root: &Path) -> Option<String> {
// 复用 read_readme 的查找逻辑(支持 README.md / README.zh.md 等变体)
let content = read_readme(root)?;
// 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
let content = read_readme_raw(root)?;
let mut text = String::new();
let mut started = false;
for raw_line in content.lines() {
@@ -171,16 +322,28 @@ pub fn extract_description(root: &Path) -> Option<String> {
/// extract_description 最大字符数
const EXTRACT_DESC_MAX: usize = 200;
/// 项目采样结果 — README 首段 + 目录树(2层) + 清单文件片段
/// 内容图引用(README 内的架构图/截图等,喂 vision 用)。
/// 采样层只收集 alt+src,Phase 2 上线后由 commands 层读 base64 喂 vision。
/// 当前 ChatMessage.content:String(F-260614-05 未做)走纯文本降级,
/// 此结构仅为采样层留接口,不读 base64。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageRef {
pub alt: String,
pub src: String,
}
/// 项目采样结果 — README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用
#[derive(Debug, Clone)]
pub struct ProjectSample {
pub readme: Option<String>,
pub tree: Vec<String>,
/// (文件名, 截断内容)
pub manifests: Vec<(String, String)>,
/// README 内的内容图引用(架构图/截图等,跳徽章)
pub images: Vec<ImageRef>,
}
const SAMPLE_README_MAX: usize = 2000;
const SAMPLE_README_MAX: usize = 8000;
const SAMPLE_MANIFEST_MAX: usize = 1500;
const SAMPLE_TREE_MAX: usize = 80;
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
@@ -190,30 +353,260 @@ const SAMPLE_IGNORED_DIRS: &[&str] = &[
".turbo", ".angular", ".gradle", "vendor",
];
/// 采集项目采样(README + 目录树 + 清单),供 LLM 分析填基础信息
/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let raw = read_readme_raw(root);
let (readme, images) = match raw {
Some(text) => {
let images = collect_images(&text);
let cleaned = strip_readme_noise(&text);
if cleaned.trim().is_empty() {
(None, images)
} else {
(Some(truncate_chars(&cleaned, SAMPLE_README_MAX)), images)
}
}
None => (None, Vec::new()),
};
Ok(ProjectSample {
readme: read_readme(root),
readme,
tree: collect_tree(root),
manifests: collect_manifests(root),
images,
})
}
fn read_readme(root: &Path) -> Option<String> {
/// 读 README 原始内容(不做处理)
fn read_readme_raw(root: &Path) -> Option<String> {
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
let p = root.join(name);
if p.is_file() {
if let Ok(content) = std::fs::read_to_string(&p) {
return Some(truncate_chars(&content, SAMPLE_README_MAX));
return Some(content);
}
}
}
None
}
/// 徽章图域名(纯徽章图,跳过不喂 LLM)
const BADGE_HOSTS: &[&str] = &[
"img.shields.io",
"shields.io",
"badge.fury.io",
"badgen.net",
"badges.frapsoft.com",
"github.com/workflows", // GitHub Actions 工作流状态徽章
"travis-ci.org",
"coveralls.io",
"codecov.io",
"app.codacy.com",
"david-dm.org",
"circleci.com",
"ci.appveyor.com",
];
/// 徽章关键词(图 alt/src 含这些视为纯徽章图)
const BADGE_KEYWORDS: &[&str] = &[
"build",
"version",
"license",
"coverage",
"downloads",
"download",
"npm",
"pypi",
"crates.io",
"codeclimate",
"maintainability",
"stars",
"forks",
"issues",
"contributors",
"dependencies",
"devdependencies",
"circleci",
"travis",
"appveyor",
"coveralls",
"codecov",
];
/// 判定 markdown 图片 `![alt](src)` 是否为纯徽章(架构图/截图等保留)
fn is_badge_image(alt: &str, src: &str) -> bool {
// 域名命中(shields.io / badge.fury / GitHub Actions 等)
let src_lower = src.to_lowercase();
if BADGE_HOSTS.iter().any(|h| src_lower.contains(h)) {
return true;
}
// 关键词命中(alt 或 src 含 build/version/license/coverage 等)
let hay = format!("{alt} {src}").to_lowercase();
if BADGE_KEYWORDS.iter().any(|k| hay.contains(k)) {
return true;
}
false
}
/// 从 README 收集内容图(跳徽章),保留 markdown 原样引用
fn collect_images(content: &str) -> Vec<ImageRef> {
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
// 匹配 ![alt](src) —— 简单行级扫描,够用且无 regex 依赖
for line in content.lines() {
let mut rest = line;
while let Some(start) = rest.find("![") {
let after_bracket = &rest[start + 2..];
let Some(alt_end) = after_bracket.find("](") else { break };
let alt = after_bracket[..alt_end].to_string();
let after_paren = &after_bracket[alt_end + 2..];
let Some(paren_end) = after_paren.find(')') else { break };
let src = after_paren[..paren_end].to_string();
rest = &after_paren[paren_end + 1..];
if is_badge_image(&alt, &src) {
continue;
}
// 同 src 去重
if seen.insert(src.clone()) {
out.push(ImageRef { alt, src });
}
if out.len() >= 20 {
return out;
}
}
}
out
}
/// 剥 README 噪音:frontmatter(YAML/TOML 块) / HTML 注释 / TOC(纯链接目录行) /
/// 纯徽章图片行 / 纯徽章 HTML 行。保留标题、正文、内容图 markdown 原样。
fn strip_readme_noise(content: &str) -> String {
let mut out = Vec::new();
let mut lines = content.lines().peekable();
while let Some(line) = lines.next() {
let trimmed = line.trim();
// ── frontmatter 块(YAML `---` / TOML `+++`)──
if trimmed == "---" || trimmed == "+++" {
// 开头处的 frontmatter:跳到下一个匹配分隔符
let delim = trimmed;
let mut consumed_any = false;
for next in lines.by_ref() {
consumed_any = true;
if next.trim() == delim {
break;
}
}
let _ = consumed_any;
continue;
}
// ── HTML 注释块(<!-- ... -->,可能跨行)──
if trimmed.starts_with("<!--") {
if trimmed.contains("-->") {
// 单行注释,整行跳过
continue;
}
// 多行:吃到含 --> 的行
for next in lines.by_ref() {
if next.contains("-->") {
break;
}
}
continue;
}
// ── 纯徽章行:整行只剩图片/HTML 徽章(可能多个 ![..](..) 连排)──
if is_pure_badge_line(trimmed) {
continue;
}
// ── TOC:纯目录行(整行只有 [..](#anchor) 锚点链接,或 markdown 列表项仅锚点)──
if is_toc_line(trimmed) {
continue;
}
out.push(line.to_string());
}
out.join("\n")
}
/// 判定整行是否为纯徽章行(仅含图片/HTML badge,无其它实质文本)。
/// 如 `![build](https://img.shields.io/x) ![license](...) <a href="..."><img.../></a>`
fn is_pure_badge_line(line: &str) -> bool {
if line.is_empty() {
return false;
}
// 仅含图片语法/HTML 标签/空白/[alt] 片段,且至少有一个图片或 HTML img 标签
let mut content_found = false;
let mut text_only: String = String::new();
let mut rest = line;
loop {
// 找下一个 ![ 或 <img 或 <a
let img_md = rest.find("![");
let img_html = rest.to_lowercase().find("<img");
let anchor_html = rest.to_lowercase().find("<a ");
let earliest = [img_md, img_html, anchor_html]
.into_iter()
.flatten()
.min();
let Some(pos) = earliest else {
// 剩余纯文本
text_only.push_str(rest);
break;
};
// pos 之前的文本进 text_only
text_only.push_str(&rest[..pos]);
let suffix = &rest[pos..];
let consumed = if suffix.starts_with("![") {
// markdown 图片:吃到 )
if let Some(end) = suffix.find(')') {
let alt = &suffix[2..suffix.find("](").unwrap_or(end)];
let src = &suffix[suffix.find("](").map(|i| i + 2).unwrap_or(end)..end];
if !is_badge_image(alt, src) {
// 内容图混在行里 → 非纯徽章行
return false;
}
content_found = true;
end + 1
} else {
// 不闭合,当普通文本
return false;
}
} else {
// HTML <img 或 <a 标签:视为徽章载体(HTML 行常见于此)
// 找 > 闭合
if let Some(end) = suffix.find('>') {
content_found = true;
end + 1
} else {
return false;
}
};
rest = &suffix[consumed..];
}
// 剩余 text_only 必须为空或纯空白/标点
let residual = text_only
.chars()
.filter(|c| !c.is_whitespace() && *c != '|' && *c != '-')
.collect::<String>();
content_found && residual.is_empty()
}
/// 判定 TOC 行:markdown 列表项仅含锚点链接 `- [..](#..)` 或整行仅锚点链接
fn is_toc_line(line: &str) -> bool {
if line.is_empty() {
return false;
}
let t = line.trim_start_matches(['-', '*', '+', ' ']);
// 形如 [text](#anchor) 或 [text](#anchor "title")
if !(t.starts_with('[') && t.contains("](")) {
return false;
}
// 取 ]( 后到行尾或空格,须以 # 开头
let Some(close) = t.find("](") else { return false };
let after = &t[close + 2..];
let target = after.trim_end_matches(')').split_whitespace().next().unwrap_or("");
target.starts_with('#')
}
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
fn collect_tree(root: &Path) -> Vec<String> {
let mut lines = Vec::new();
@@ -344,14 +737,25 @@ mod tests {
#[test]
fn collects_sample() {
let d = scratch("sample");
fs::write(d.join("README.md"), "# Test\nA test project.\nMore.").unwrap();
fs::write(
d.join("README.md"),
"# Test\n\n![badge](https://img.shields.io/badge/x-y-green)\n\n![arch](./docs/arch.png)\n\nA test project.\nMore.",
)
.unwrap();
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
fs::create_dir(d.join("src")).unwrap();
fs::write(d.join("src/main.ts"), "x").unwrap();
fs::create_dir(d.join("node_modules")).unwrap();
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
let s = collect_sample(&d).unwrap();
assert!(s.readme.as_deref().unwrap_or("").contains("test project"));
// 徽章行剥,内容图 + 正文保留
let readme = s.readme.as_deref().unwrap_or("");
assert!(readme.contains("test project"), "readme={readme}");
assert!(!readme.contains("shields.io"), "徽章未剥: {readme}");
assert!(readme.contains("docs/arch.png"), "内容图丢失: {readme}");
// 内容图收集(badge 跳,arch 留)
assert_eq!(s.images.len(), 1);
assert_eq!(s.images[0].src, "./docs/arch.png");
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
assert!(s.tree.iter().any(|t| t.contains("src")));
// node_modules 应被过滤
@@ -359,6 +763,103 @@ mod tests {
fs::remove_dir_all(&d).ok();
}
#[test]
fn strips_frontmatter_and_toc_and_badges() {
let d = scratch("noise");
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n![build](https://img.shields.io/badge/build-passing)\n\nThis is the real intro.\n";
fs::write(d.join("README.md"), readme).unwrap();
let s = collect_sample(&d).unwrap();
let r = s.readme.as_deref().unwrap_or("");
assert!(r.contains("# Foo"), "标题应保留: {r}");
assert!(r.contains("real intro"), "正文应保留: {r}");
assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}");
assert!(!r.contains("shields.io"), "徽章未剥: {r}");
assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}");
assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}");
assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn image_collection_skips_badges() {
let content = "![build](https://img.shields.io/x)\n![arch](./a.png)\n![version](https://badge.fury.io/js/y)\n![screenshot](screens/b.png)\n";
let imgs = collect_images(content);
// 只有 arch + screenshot,build/version 跳
assert_eq!(imgs.len(), 2);
assert!(imgs.iter().any(|i| i.src == "./a.png"));
assert!(imgs.iter().any(|i| i.src == "screens/b.png"));
assert!(imgs.iter().all(|i| !i.src.contains("shields.io")));
assert!(imgs.iter().all(|i| !i.src.contains("badge.fury")));
}
#[test]
fn detects_monorepo_pnpm() {
let d = scratch("mono-pnpm");
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
assert!(is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_monorepo_npm_workspaces() {
let d = scratch("mono-npm");
fs::write(
d.join("package.json"),
r#"{"name":"root","workspaces":["packages/*"]}"#,
)
.unwrap();
assert!(is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_non_monorepo() {
let d = scratch("nonmono");
fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap();
assert!(!is_monorepo(&d));
fs::remove_dir_all(&d).ok();
}
#[test]
fn discover_monorepo_children() {
let d = scratch("discover-mono");
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
// 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml)
fs::create_dir_all(d.join("packages/web")).unwrap();
fs::write(
d.join("packages/web/package.json"),
r#"{"name":"web","dependencies":{"vue":"3"}}"#,
)
.unwrap();
fs::create_dir_all(d.join("packages/cli")).unwrap();
fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap();
// 空 stack 子目录应过滤
fs::create_dir_all(d.join("packages/empty")).unwrap();
fs::write(d.join("packages/empty/x.txt"), "x").unwrap();
let found = discover_projects(&d).unwrap();
// 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"web"), "names={names:?}");
assert!(names.contains(&"cli"), "names={names:?}");
assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn discover_flat_children() {
// 非 monorepo:扫根直接子目录中 detect_stack 非空的
let d = scratch("discover-flat");
fs::create_dir_all(d.join("proj-a")).unwrap();
fs::write(d.join("proj-a/Cargo.toml"), "").unwrap();
fs::create_dir_all(d.join("not-a-project")).unwrap();
fs::write(d.join("not-a-project/readme.txt"), "x").unwrap();
let found = discover_projects(&d).unwrap();
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"proj-a"), "names={names:?}");
assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}");
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_skips_title_badge() {
let d = scratch("desc");