//! 历史项目发现 — 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::(&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, /// 是否为 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> { if !root.is_dir() { anyhow::bail!("路径不是目录: {}", root.display()); } let mut out: Vec = 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"))); } }