277 lines
9.3 KiB
Rust
277 lines
9.3 KiB
Rust
//! 历史项目发现 — 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")));
|
|
}
|
|
}
|