Files
DevFlow/crates/df-project/src/scan.rs
绝尘 d5a641797c 优化: 项目管理阻塞去重 + normalize_path/白名单抽公共
- collect_sample/detect_stack 三处包 spawn_blocking 防 async 阻塞 + bind_directory 补漏一处
- normalize_path 抽公共到 df-project/scan(删 project.rs 本地 + tool_registry inline 闭包)
- tool_registry update_project 复用 crud is_allowed_column(删硬编码白名单,与 CRUD 同源)

Sprint 20 审查 ③④⑤。todo 记 A/B 验证发现的 2 项非阻断(T-09 idea 补偿删级联 / T-10 normalize_path 同步)
2026-06-14 14:21:24 +08:00

317 lines
11 KiB
Rust

//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
//!
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
//!
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
use std::path::Path;
use anyhow::{Context, Result};
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
/// 统一正斜杠 + 小写。防 `C:\a\b` vs `C:/a/b/` 绕过重复检查。
/// 注:仅用于比较,存库保留用户输入的原始可读路径。
pub fn normalize_path(p: &str) -> String {
match Path::new(p).canonicalize() {
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
Err(_) => p
.trim_end_matches(['\\', '/'])
.replace('\\', "/")
.to_lowercase(),
}
}
/// 探测目录的技术栈
///
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
let mut stack: Vec<String> = Vec::new();
// ── 后端/系统语言 ──
if root.join("Cargo.toml").exists() {
push_unique(&mut stack, "rust");
}
if root.join("go.mod").exists() {
push_unique(&mut stack, "go");
}
if root.join("pom.xml").exists()
|| root.join("build.gradle").exists()
|| root.join("build.gradle.kts").exists()
{
push_unique(&mut stack, "java");
}
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
push_unique(&mut stack, "python");
}
// C#: 根目录存在 .csproj 文件(仅一层)
if has_file_with_ext(root, "csproj") {
push_unique(&mut stack, "csharp");
}
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
if root.join("src-tauri").is_dir() {
push_unique(&mut stack, "tauri");
}
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
if root.join("package.json").exists() {
if let Ok(deps) = read_package_deps(root.join("package.json")) {
if deps.iter().any(|d| d == "vue") {
push_unique(&mut stack, "vue");
}
if deps.iter().any(|d| d == "react" || d == "react-dom") {
push_unique(&mut stack, "react");
}
if deps.iter().any(|d| d == "@angular/core") {
push_unique(&mut stack, "angular");
}
if deps.iter().any(|d| d == "svelte") {
push_unique(&mut stack, "svelte");
}
if deps.iter().any(|d| d == "next") {
push_unique(&mut stack, "next");
}
if deps.iter().any(|d| d == "vite") {
push_unique(&mut stack, "vite");
}
if deps.iter().any(|d| d == "typescript") {
push_unique(&mut stack, "typescript");
}
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
push_unique(&mut stack, "node");
}
}
}
Ok(stack)
}
/// 解析 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())
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
let mut names = Vec::new();
for key in &["dependencies", "devDependencies"] {
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
for k in obj.keys() {
names.push(k.clone());
}
}
}
Ok(names)
}
/// 目录下是否存在指定扩展名的文件(仅一层)
fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
let Ok(entries) = std::fs::read_dir(dir) else {
return false;
};
entries.flatten().any(|e| {
e.path()
.extension()
.and_then(|x| x.to_str())
.map(|x| x == ext)
.unwrap_or(false)
})
}
// ============================================================
// 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码
// ============================================================
/// 项目采样结果 — README 首段 + 目录树(2层) + 清单文件片段
#[derive(Debug, Clone)]
pub struct ProjectSample {
pub readme: Option<String>,
pub tree: Vec<String>,
/// (文件名, 截断内容)
pub manifests: Vec<(String, String)>,
}
const SAMPLE_README_MAX: usize = 2000;
const SAMPLE_MANIFEST_MAX: usize = 1500;
const SAMPLE_TREE_MAX: usize = 80;
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
const SAMPLE_IGNORED_DIRS: &[&str] = &[
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
".turbo", ".angular", ".gradle", "vendor",
];
/// 采集项目采样(README + 目录树 + 清单),供 LLM 分析填基础信息
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
if !root.is_dir() {
anyhow::bail!("路径不是目录: {}", root.display());
}
Ok(ProjectSample {
readme: read_readme(root),
tree: collect_tree(root),
manifests: collect_manifests(root),
})
}
fn read_readme(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));
}
}
}
None
}
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
fn collect_tree(root: &Path) -> Vec<String> {
let mut lines = Vec::new();
let mut count = 0usize;
collect_tree_level(root, "", &mut lines, &mut count, false);
lines
}
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
if *count >= SAMPLE_TREE_MAX {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut items: Vec<_> = entries.flatten().collect();
items.sort_by_key(|e| e.file_name());
for e in items {
if *count >= SAMPLE_TREE_MAX {
return;
}
let name = e.file_name().to_string_lossy().to_string();
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
continue;
}
lines.push(format!("{}{}/", prefix, name));
*count += 1;
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
if !is_sub {
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
}
} else {
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
if name.starts_with('.') && name != ".gitignore" {
continue;
}
lines.push(format!("{}{}", prefix, name));
*count += 1;
}
}
}
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
let mut out = Vec::new();
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
let p = root.join(name);
if let Ok(content) = std::fs::read_to_string(&p) {
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
}
}
out
}
/// 按字符数截断(避免截断 UTF-8 多字节边界)
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let truncated: String = s.chars().take(max).collect();
format!("{}…(已截断)", truncated)
}
/// 去重 push
fn push_unique(stack: &mut Vec<String>, s: &str) {
if !stack.iter().any(|x| x == s) {
stack.push(s.to_string());
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
fn scratch(name: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&p);
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn non_dir_errors() {
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
assert!(r.is_err());
}
#[test]
fn detects_rust() {
let d = scratch("rust");
fs::write(d.join("Cargo.toml"), "").unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn detects_full_stack() {
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
let d = scratch("full");
fs::write(d.join("Cargo.toml"), "").unwrap();
fs::create_dir(d.join("src-tauri")).unwrap();
fs::write(
d.join("package.json"),
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
)
.unwrap();
let s = detect_stack(&d).unwrap();
assert!(s.contains(&"rust".to_string()));
assert!(s.contains(&"tauri".to_string()));
assert!(s.contains(&"vue".to_string()));
assert!(s.contains(&"vite".to_string()));
assert!(s.contains(&"typescript".to_string()));
fs::remove_dir_all(&d).ok();
}
#[test]
fn empty_dir_returns_empty() {
let d = scratch("empty");
let s = detect_stack(&d).unwrap();
assert!(s.is_empty());
fs::remove_dir_all(&d).ok();
}
#[test]
fn collects_sample() {
let d = scratch("sample");
fs::write(d.join("README.md"), "# Test\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"));
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
assert!(s.tree.iter().any(|t| t.contains("src")));
// node_modules 应被过滤
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
fs::remove_dir_all(&d).ok();
}
}