重构: scan.rs拆4模块(stack/discover/readme/sample)共享常量提mod.rs

This commit is contained in:
2026-06-22 01:45:43 +08:00
parent 2a13f45650
commit d1d3871a88
6 changed files with 855 additions and 741 deletions

View File

@@ -1,741 +0,0 @@
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
//!
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
//!
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
//!
//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分),
//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。
use std::path::Path;
use anyhow::{Context, Result};
#[path = "scan_helpers.rs"]
mod scan_helpers;
pub use scan_helpers::ImageRef;
use scan_helpers::{collect_images, strip_readme_noise};
/// 规范化路径用于比较: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)
}
// ============================================================
// 历史项目发现 — 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)。数组(`["packages/*"]`)或对象
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
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_and(|v| !v.is_null()) {
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())
.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 首段(首个非标题非空段落,截断 200 字)。
///
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
pub fn extract_description(root: &Path) -> Option<String> {
// 复用 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() {
let line = raw_line.trim();
if line.is_empty() {
if started {
break; // 段落结束
}
continue; // 首段尚未开始,跳过开头空行
}
// 段落开始后不再跳行,直接累加
if !started {
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
if line.starts_with('#')
|| line.starts_with("<!--")
|| line.starts_with('!')
|| line.starts_with('<')
{
continue;
}
started = true;
}
if !text.is_empty() {
text.push(' ');
}
text.push_str(line);
}
let desc = text.trim().to_string();
if desc.is_empty() {
return None;
}
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
}
/// extract_description 最大字符数
const EXTRACT_DESC_MAX: usize = 200;
/// 项目采样结果 — 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 = 8000;
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 原始内容(不做处理)
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(content);
}
}
}
None
}
/// 采集项目采样(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,
tree: collect_tree(root),
manifests: collect_manifests(root),
images,
})
}
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
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\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();
// 徽章行剥,内容图 + 正文保留
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 应被过滤
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
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 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");
// 标题 + badge 图片行应跳过,首段为正文
fs::write(
d.join("README.md"),
"# My Project\n\n![badge](https://x.io/badge.svg)\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
)
.unwrap();
let desc = extract_description(&d).unwrap();
assert!(desc.contains("示例项目"));
assert!(desc.contains("第二行正文"));
assert!(!desc.contains("My Project"));
assert!(!desc.contains("badge"));
assert!(!desc.contains("安装"));
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_no_readme_returns_none() {
let d = scratch("nodesc");
assert!(extract_description(&d).is_none());
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_truncates_long() {
let d = scratch("longdesc");
let long = "".repeat(500);
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
let desc = extract_description(&d).unwrap();
// 截断到 200 字 + 末尾「…(已截断)」标记
assert!(desc.chars().count() <= 210);
assert!(desc.contains("已截断"));
fs::remove_dir_all(&d).ok();
}
// ── normalize_path 跨平台归一化 ──
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
// 不断言精确串,避免与平台耦合。
#[test]
fn normalize_path_uses_forward_slash_and_lowercase() {
// 不存在的路径走降级分支(trim + replace + lowercase)
let n = normalize_path(r"C:\Foo\Bar\");
assert!(
!n.contains('\\'),
"反斜杠未归一: {n}"
);
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
assert!(
!n.ends_with('/') && !n.ends_with('\\'),
"尾部分隔符未裁剪: {n}"
);
}
#[test]
fn normalize_path_equivalent_inputs_equal() {
// 两种写法应归一为同一串(去重场景)
let a = normalize_path(r"C:\Foo\Bar");
let b = normalize_path("C:/Foo/Bar/");
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
}
#[test]
fn normalize_path_relative_is_lowercase_and_forward_slash() {
let n = normalize_path(r"..\Some\Path");
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
}
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
#[test]
fn is_monorepo_workspaces_null_not_misclassified() {
let d = scratch("ws-null");
fs::write(
d.join("package.json"),
r#"{"name":"x","workspaces":null}"#,
)
.unwrap();
assert!(
!is_monorepo(&d),
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
);
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_workspaces_object_treated_as_real() {
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
let d = scratch("ws-obj");
fs::write(
d.join("package.json"),
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
)
.unwrap();
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_non_dir_returns_false() {
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
}
}

View File

@@ -0,0 +1,276 @@
//! 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)。
use std::path::Path;
use anyhow::Result;
use super::stack::{detect_stack, has_file_with_ext};
use super::SAMPLE_IGNORED_DIRS;
/// 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)。数组(`["packages/*"]`)或对象
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
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_and(|v| !v.is_null()) {
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")
}
#[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 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();
}
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
#[test]
fn is_monorepo_workspaces_null_not_misclassified() {
let d = scratch("ws-null");
fs::write(
d.join("package.json"),
r#"{"name":"x","workspaces":null}"#,
)
.unwrap();
assert!(
!is_monorepo(&d),
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
);
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_workspaces_object_treated_as_real() {
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
let d = scratch("ws-obj");
fs::write(
d.join("package.json"),
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
)
.unwrap();
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_non_dir_returns_false() {
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
}
}

View File

@@ -0,0 +1,114 @@
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
//!
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
//!
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
//!
//! ## 模块拆分(2026-06-22,REFACTOR-260619-10)
//!
//! 原 `scan.rs`(741 行)按职责拆为 4 子模块,纯文件重组、零行为变化:
//! - [`stack`]:技术栈探测(`detect_stack` + package.json 解析)
//! - [`discover`]:历史项目发现/monorepo 展开(`discover_projects`/`is_monorepo`)
//! - [`readme`]:README 读取与首段抽取(`read_readme_raw`/`extract_description`)
//! - [`sample`]:项目采样(`collect_sample` + 目录树/清单/噪音目录)
//!
//! 跨模块共享的常量/helper 留在本 `mod.rs`(`SAMPLE_IGNORED_DIRS`/`truncate_chars`),
//! 子模块经 `use super::*` 引用。`pub use` 重导出保持外部 `df_project::scan::*` 路径不变。
//!
//! README 噪音处理与图片收集已抽离至 `scan_helpers`(2026-06-19 自底向上拆分),
//! 此处通过 `pub use` 重导出 `ImageRef` 等保持外部路径 `df_project::scan::*` 不变。
use std::path::Path;
#[path = "../scan_helpers.rs"]
mod scan_helpers;
pub use scan_helpers::ImageRef;
pub mod discover;
pub mod readme;
pub mod sample;
pub mod stack;
// ── 对外稳定 API 重导出(外部 `df_project::scan::*` 路径不变)──
pub use discover::{discover_projects, is_monorepo, DiscoveredProject};
pub use readme::extract_description;
pub use sample::{collect_sample, ProjectSample};
pub use stack::detect_stack;
// ============================================================
// 跨模块共享常量/helper
// ============================================================
/// 目录树/项目发现过滤的噪音目录(依赖产物/构建/缓存/IDE)。
///
/// 同时被 [`discover`] 与 [`sample`] 引用,故置于本 `mod.rs`,`pub(super)` 暴露给子模块。
pub(super) 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",
];
/// 按字符数截断(避免截断 UTF-8 多字节边界)。
///
/// 被 [`readme`](crate::scan::readme) 与 [`sample`](crate::scan::sample) 引用,故置于本 `mod.rs`。
pub(super) 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)
}
/// 规范化路径用于比较: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(),
}
}
#[cfg(test)]
mod tests {
use super::*;
// ── normalize_path 跨平台归一化 ──
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
// 不断言精确串,避免与平台耦合。
#[test]
fn normalize_path_uses_forward_slash_and_lowercase() {
// 不存在的路径走降级分支(trim + replace + lowercase)
let n = normalize_path(r"C:\Foo\Bar\");
assert!(
!n.contains('\\'),
"反斜杠未归一: {n}"
);
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
assert!(
!n.ends_with('/') && !n.ends_with('\\'),
"尾部分隔符未裁剪: {n}"
);
}
#[test]
fn normalize_path_equivalent_inputs_equal() {
// 两种写法应归一为同一串(去重场景)
let a = normalize_path(r"C:\Foo\Bar");
let b = normalize_path("C:/Foo/Bar/");
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
}
#[test]
fn normalize_path_relative_is_lowercase_and_forward_slash() {
let n = normalize_path(r"..\Some\Path");
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
}
}

View File

@@ -0,0 +1,122 @@
//! README 读取与首段抽取 — 纯 IO + 文本规则。
//!
//! `read_readme_raw` 被本模块(`extract_description`)与 [`super::sample`](`crate::scan::sample`)
//! (`collect_sample`)共用,故 `pub(super)` 暴露。
use std::path::Path;
use super::truncate_chars;
/// extract_description 最大字符数
const EXTRACT_DESC_MAX: usize = 200;
/// 读 README 原始内容(不做处理)。
///
/// 支持多种 README 变体(`README.md` / `README.zh.md` / `README_zh.md` / `README_EN.md` 等),
/// 返回首个存在的文件内容。
pub(super) 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(content);
}
}
}
None
}
/// 提取项目描述 — 读 README 首段(首个非标题非空段落,截断 200 字)。
///
/// 用于「导入历史项目」时自动填充 description。无 README 或解析失败返回 None。
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
pub fn extract_description(root: &Path) -> Option<String> {
// 复用 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() {
let line = raw_line.trim();
if line.is_empty() {
if started {
break; // 段落结束
}
continue; // 首段尚未开始,跳过开头空行
}
// 段落开始后不再跳行,直接累加
if !started {
// 跳过标题 / HTML 注释 / badge 图片 / HTML 标签等噪声前导行
if line.starts_with('#')
|| line.starts_with("<!--")
|| line.starts_with('!')
|| line.starts_with('<')
{
continue;
}
started = true;
}
if !text.is_empty() {
text.push(' ');
}
text.push_str(line);
}
let desc = text.trim().to_string();
if desc.is_empty() {
return None;
}
Some(truncate_chars(&desc, EXTRACT_DESC_MAX))
}
#[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 extract_desc_skips_title_badge() {
let d = scratch("desc");
// 标题 + badge 图片行应跳过,首段为正文
fs::write(
d.join("README.md"),
"# My Project\n\n![badge](https://x.io/badge.svg)\n\n这是一个示例项目,用于演示。\n第二行正文。\n\n## 安装",
)
.unwrap();
let desc = extract_description(&d).unwrap();
assert!(desc.contains("示例项目"));
assert!(desc.contains("第二行正文"));
assert!(!desc.contains("My Project"));
assert!(!desc.contains("badge"));
assert!(!desc.contains("安装"));
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_no_readme_returns_none() {
let d = scratch("nodesc");
assert!(extract_description(&d).is_none());
fs::remove_dir_all(&d).ok();
}
#[test]
fn extract_desc_truncates_long() {
let d = scratch("longdesc");
let long = "".repeat(500);
fs::write(d.join("README.md"), format!("# T\n\n{long}")).unwrap();
let desc = extract_description(&d).unwrap();
// 截断到 200 字 + 末尾「…(已截断)」标记
assert!(desc.chars().count() <= 210);
assert!(desc.contains("已截断"));
fs::remove_dir_all(&d).ok();
}
}

View File

@@ -0,0 +1,167 @@
//! 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码。
//!
//! README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用。
use std::path::Path;
use anyhow::Result;
use super::readme::read_readme_raw;
use super::scan_helpers::{collect_images, strip_readme_noise, ImageRef};
use super::{truncate_chars, SAMPLE_IGNORED_DIRS};
const SAMPLE_README_MAX: usize = 8000;
const SAMPLE_MANIFEST_MAX: usize = 1500;
const SAMPLE_TREE_MAX: usize = 80;
/// 项目采样结果 — 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>,
}
/// 采集项目采样(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,
tree: collect_tree(root),
manifests: collect_manifests(root),
images,
})
}
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
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
}
#[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 collects_sample() {
let d = scratch("sample");
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();
// 徽章行剥,内容图 + 正文保留
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 应被过滤
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
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();
}
}

View File

@@ -0,0 +1,176 @@
//! 技术栈探测 — 浅读根目录标志文件识别技术栈。
//!
//! 纯函数、零状态。读 `Cargo.toml`/`go.mod`/`package.json` 等标志,
//! 不递归遍历(控制性能与安全)。
use std::path::Path;
use anyhow::{Context, Result};
/// 探测目录的技术栈
///
/// 返回去重后的技术栈标签数组(如 `["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)
}
/// 目录下是否存在指定扩展名的文件(仅一层)。
///
/// 被 `detect_stack`(C# csproj)与 `super::discover`(项目清单判定)共用,
/// 故 `pub(super)`。
pub(super) 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)
})
}
/// 去重 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();
}
}